diff --git a/README.md b/README.md index 3b39ddfc..40867a36 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,13 @@ 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. +Use `scans` to browse previous scans, `scans show` to inspect the latest +completed scan, and `findings` to list saved findings for the current repository. +To review every finding from an earlier scan, including results beyond the first +page, run `findings list --scan SCAN_ID --offset 20`. Use +`findings show OCCURRENCE_ID` for the complete finding and any saved cross-scan +links. + `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 diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 1fe6764e..e9b445a5 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -217,11 +217,16 @@ npx @openai/codex-security bulk-scan repositories.csv --output-dir /path/outside npx @openai/codex-security bulk-scan repositories.csv --output-dir /path/outside/repositories/security-scans --scan-prompt-file scan.md --post-scan-prompt-file follow-up.md npx @openai/codex-security scans list /path/to/repository npx @openai/codex-security scans list --scan-root /path/outside/repository/results +npx @openai/codex-security scans show npx @openai/codex-security scans show SCAN_ID 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 +npx @openai/codex-security findings list --severity high --status open +npx @openai/codex-security findings list --scan SCAN_ID --offset 20 +npx @openai/codex-security findings show OCCURRENCE_ID 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 @@ -502,11 +507,28 @@ Results remain under `--output-dir`; rerun the same command to resume. ### Scan history and reruns -`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 -`--show-linked-findings` to include finding links from previous scans. +`npx @openai/codex-security scans` lists previous scans for the current +repository. Use `scans list REPOSITORY` to inspect another checkout, or +`scans list --scan-root DIR` to list scans whose artifacts are under a particular +root. `scans show` opens the latest completed scan; +`scans show SCAN_ID` selects another saved scan. Both include scan configuration, +results, coverage, and artifact locations. + +Run `findings` or `findings list` to browse active findings for the current +repository across saved scans. Add `--all-repositories` to include every saved +repository, or `--scan SCAN_ID` to inspect all findings from one previous scan. +Filter results with `--query TEXT`, `--severity LEVEL`, or +`--status open|closed`; use `--offset N` and `--limit N` to page through the +complete set. Pages contain at most 20 findings. + +`findings show OCCURRENCE_ID` opens the selected finding, its remediation advice, +and any saved links to previous occurrences. Finding lists include the +occurrence IDs needed by `findings show` and `findings false-positive`. + +Add `--show-linked-findings` to `scans show` to include previously saved finding +links. Links appear after running `scans compare BEFORE AFTER` or +`scans match --all`; creating uncached matches starts a Codex comparison and +saves the result. Every scan history command accepts a full scan ID or a unique prefix of at least eight characters. diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py index 48f17e24..c77a3870 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py @@ -114,6 +114,9 @@ def parse_args(description: str) -> argparse.Namespace: get_scan.add_argument("--scan-id", required=True) get_scan.add_argument("--occurrence-id") + get_finding = subparsers.add_parser("get-finding") + get_finding.add_argument("--occurrence-id", required=True) + get_scan_feedback = subparsers.add_parser("get-scan-feedback") get_scan_feedback.add_argument("--scan-id", required=True) @@ -165,7 +168,9 @@ def parse_args(description: str) -> argparse.Namespace: list_global_findings.add_argument("--query") list_global_findings.add_argument("--severity", choices=FINDING_SEVERITIES) list_global_findings.add_argument("--status", choices=FINDING_STATUSES) - list_global_findings.add_argument("--target-id") + list_global_findings.add_argument("--repository") + list_global_findings.add_argument("--target-id", action="append") + list_global_findings.add_argument("--target-path", action="append") list_global_findings.add_argument("--offset", type=non_negative_int, default=0) list_global_findings.add_argument("--limit", type=positive_int, default=FINDINGS_PAGE_MAX) list_repositories = subparsers.add_parser("list-repositories") diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index b7c7395f..289a02ef 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -2749,6 +2749,9 @@ def scan_result( "completed": independent_reviews["completed"], "consolidating": independent_reviews["consolidating"], } + current_target = connection.execute( + "SELECT current_path FROM security_targets WHERE id = ?", (scan["target_id"],) + ).fetchone() return { "artifacts": artifacts, "canceledAt": scan["canceled_at"], @@ -2775,6 +2778,12 @@ def scan_result( "scanId": scan["id"], "scope": scan["scope"], "targetPath": scan["target_path"], + **( + {"currentTargetPath": current_target["current_path"]} + if current_target is not None + and current_target["current_path"] != scan["target_path"] + else {} + ), "targetRevision": scan["target_revision"], "targetSummary": scan["target_summary"], "updatedAt": max( @@ -2901,17 +2910,46 @@ def finding_result( connection: sqlite3.Connection, scan: sqlite3.Row, occurrence: sqlite3.Row, + *, + full_details: bool = False, ) -> dict[str, Any]: - details = bounded_finding_details(read_finding_details(occurrence["details_json"])) + stored_details = read_finding_details(occurrence["details_json"]) + details = dict(stored_details if full_details else bounded_finding_details(stored_details)) + for field in ( + "artifactPaths", + "currentTargetPath", + "knownScanIds", + "knownSince", + "matches", + "occurrenceCount", + "scanDir", + "scanId", + "sourceExcerpt", + "status", + "targetId", + "targetPath", + "updatedAt", + ): + details.pop(field, None) confidence = details.get("confidence") confidence = confidence if isinstance(confidence, dict) else {} severity = details.get("severity") severity = severity if isinstance(severity, dict) else {} locations = [] try: - target = require_scan_target_identity(scan) + target = require_scan_target_identity(scan, target_path=scan["target_path"]) except SystemExit: - target = None + current_target = connection.execute( + "SELECT current_path FROM security_targets WHERE id = ?", (scan["target_id"],) + ).fetchone() + try: + target = ( + require_scan_target_identity(scan, target_path=current_target["current_path"]) + if current_target is not None + else None + ) + except SystemExit: + target = None for row in connection.execute( """ SELECT relative_path, start_line, end_line, role @@ -2920,24 +2958,33 @@ def finding_result( ORDER BY CASE WHEN role = 'root_control' THEN 0 ELSE 1 END, sort_order LIMIT ? """, - (occurrence["id"], FINDING_LOCATIONS_LIMIT), + (occurrence["id"], -1 if full_details else FINDING_LOCATIONS_LIMIT), ): absolute_path = safe_source_path(target, row["relative_path"]) if target else None location = { "endLine": row["end_line"], - "path": bounded_output_text(row["relative_path"], FINDING_LOCATION_PATH_BYTES), + "path": ( + row["relative_path"] + if full_details + else bounded_output_text(row["relative_path"], FINDING_LOCATION_PATH_BYTES) + ), "role": ( - bounded_output_text(row["role"], FINDING_LOCATION_ROLE_BYTES) + row["role"] + if full_details + else bounded_output_text(row["role"], FINDING_LOCATION_ROLE_BYTES) if row["role"] is not None else None ), "startLine": row["start_line"], } if absolute_path is not None: - location["absolutePath"] = bounded_output_text( - absolute_path, FINDING_ABSOLUTE_PATH_BYTES + location["absolutePath"] = ( + str(absolute_path) + if full_details + else bounded_output_text(absolute_path, FINDING_ABSOLUTE_PATH_BYTES) ) locations.append(location) + triage = finding_triage_result(connection, occurrence["id"]) result = { **details, "confidence": { @@ -2949,14 +2996,27 @@ def finding_result( "locations": locations, "occurrenceId": occurrence["id"], "remediationState": finding_remediation_result(connection, occurrence["id"]), - "remediation": bounded_output_text(occurrence["remediation"], FINDING_REMEDIATION_BYTES), + "remediation": ( + occurrence["remediation"] + if full_details + else bounded_output_text(occurrence["remediation"], FINDING_REMEDIATION_BYTES) + ), "severity": { **severity, "level": bounded_output_text(occurrence["severity"], FINDING_LEVEL_BYTES), }, - "summary": bounded_output_text(occurrence["summary"], FINDING_SUMMARY_BYTES), - "title": bounded_output_text(occurrence["title"], FINDING_TITLE_BYTES), - "triage": finding_triage_result(connection, occurrence["id"]), + "status": triage["status"], + "summary": ( + occurrence["summary"] + if full_details + else bounded_output_text(occurrence["summary"], FINDING_SUMMARY_BYTES) + ), + "title": ( + occurrence["title"] + if full_details + else bounded_output_text(occurrence["title"], FINDING_TITLE_BYTES) + ), + "triage": triage, } matches, known_since, known_scan_ids = scan_history.finding_matches( connection, occurrence["id"], scan["id"], scan["started_at"] @@ -2965,7 +3025,6 @@ def finding_result( result["matches"] = matches result["knownSince"] = known_since result["knownScanIds"] = known_scan_ids - result.pop("artifactPaths", None) source_excerpt = finding_source_excerpt(scan, target, locations) if source_excerpt: result["sourceExcerpt"] = source_excerpt @@ -3341,6 +3400,30 @@ def main() -> None: result = deep_scan.fail_deep_scan(connection, args) elif args.command == "get-scan": result = scan_context(connection, args.scan_id, args.occurrence_id) + elif args.command == "get-finding": + occurrence = require_occurrence(connection, args.occurrence_id) + scan = require_scan(connection, occurrence["scan_id"]) + backfill_legacy_finding_details(connection, scan) + occurrence = require_occurrence(connection, occurrence["id"]) + current_target = connection.execute( + "SELECT current_path FROM security_targets WHERE id = ?", (scan["target_id"],) + ).fetchone() + result = { + "scan": { + "findings": [ + finding_result(connection, scan, occurrence, full_details=True) + ], + "scanDir": scan["scan_dir"], + "scanId": scan["id"], + "targetPath": scan["target_path"], + **( + {"currentTargetPath": current_target["current_path"]} + if current_target is not None + and current_target["current_path"] != scan["target_path"] + else {} + ), + } + } elif args.command == "get-scan-feedback": result = get_scan_feedback(connection, require_scan(connection, args.scan_id)) elif args.command == "list-scans": @@ -3375,9 +3458,13 @@ def main() -> None: read_coverage=coverage_for_comparison, ) elif args.command == "list-global-findings": - result = native_indexes.list_global_findings(connection, args) + result = native_indexes.list_global_findings( + connection, args, read_coverage=coverage_for_comparison + ) elif args.command == "list-repositories": - result = native_indexes.list_repositories(connection, args) + result = native_indexes.list_repositories( + connection, args, read_coverage=coverage_for_comparison + ) elif args.command == "list-findings": result = list_findings(connection, args) elif args.command in {"update-progress", "update-scan-context"}: diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py b/sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py index 80e825fe..fd825654 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py @@ -4,7 +4,7 @@ import sqlite3 import sys from collections import Counter -from collections.abc import Iterator +from collections.abc import Callable, Iterator from itertools import islice from pathlib import Path from typing import Any @@ -19,13 +19,43 @@ def list_global_findings( connection: sqlite3.Connection, args: argparse.Namespace, + *, + read_coverage: Callable[[sqlite3.Row], dict[str, Any]], ) -> dict[str, Any]: limit = min(args.limit, FINDINGS_PAGE_MAX) query = args.query.strip().casefold() if args.query else "" + selected_ids = args.target_id + target_ids = ( + {selected_ids} + if isinstance(selected_ids, str) + else set(selected_ids) + if selected_ids is not None + else None + ) + selected_paths = getattr(args, "target_path", None) + target_paths = ( + {selected_paths} + if isinstance(selected_paths, str) + else set(selected_paths) + if selected_paths is not None + else None + ) + repository = getattr(args, "repository", None) findings = ( row - for row in _indexed_findings(connection) - if (args.target_id is None or row["target_id"] == args.target_id) + for row in _active_findings( + connection, + read_coverage, + target_ids=target_ids, + target_paths=target_paths, + repository=repository, + query=query, + ) + if ( + (target_ids is None and target_paths is None) + or (target_ids is not None and row["target_id"] in target_ids) + or (target_paths is not None and row["target_path"] in target_paths) + ) and (args.severity is None or row["severity"] == args.severity) and (args.status is None or row["status"] == args.status) and ( @@ -35,11 +65,14 @@ def list_global_findings( for value in ( row["title"], row["summary"], - row["target_path"], + row["target_path"] + if target_ids is None and target_paths is None and repository is None + else None, row["location_path"], ) if value is not None ) + or row["secondary_location_match"] ) ) rows = list(islice(findings, args.offset, args.offset + limit + 1)) @@ -70,9 +103,119 @@ def list_global_findings( } -def _indexed_findings(connection: sqlite3.Connection) -> Iterator[sqlite3.Row]: - yield from connection.execute( - """ +def _active_findings( + connection: sqlite3.Connection, + read_coverage: Callable[[sqlite3.Row], dict[str, Any]], + *, + target_ids: set[str] | None = None, + target_paths: set[str] | None = None, + repository: str | None = None, + query: str = "", +) -> Iterator[sqlite3.Row]: + target_filters = [] + target_values = [] + if target_ids: + placeholders = ", ".join("?" for _ in target_ids) + target_filters.append(f"targets.id IN ({placeholders})") + target_values.extend(target_ids) + if target_paths is not None: + placeholders = ", ".join("?" for _ in target_paths) + target_filters.append(f"scans.target_path IN ({placeholders})") + target_values.extend(target_paths) + target_filter = "" if not target_filters else "AND (" + " OR ".join(target_filters) + ")" + repository_clauses, repository_values, _, _ = ( + scan_history.repository_scan_scope(connection, repository) + if repository is not None + else ([], [], [], []) + ) + repository_filter = ( + "AND (" + " AND ".join(repository_clauses) + ")" if repository_clauses else "" + ) + target_values.extend(repository_values) + current_owner_only = ( + "AND NOT (scans.target_id IS NULL AND EXISTS (" + "SELECT 1 FROM security_targets AS path_owner " + "WHERE path_owner.current_path = scans.target_path))" + ) + scan_columns = { + column["name"] for column in connection.execute("PRAGMA table_info(scans)") + } + if {"target_device", "target_inode"}.issubset(scan_columns): + latest_identity = ( + "FROM scans AS ownership_scan " + "WHERE ownership_scan.target_id = scans.target_id " + "AND ownership_scan.target_device IS NOT NULL " + "AND ownership_scan.target_inode IS NOT NULL " + "ORDER BY ownership_scan.rowid DESC LIMIT 1" + ) + current_owner_only += ( + " AND (scans.target_id IS NULL " + "OR (scans.target_device IS NULL AND scans.target_inode IS NULL) OR (" + f"scans.target_device IS (SELECT ownership_scan.target_device {latest_identity}) " + f"AND scans.target_inode IS (SELECT ownership_scan.target_inode {latest_identity})" + "))" + ) + replaced_targets = [] + transitioned_targets = [] + ownership_epochs = [] + for target in connection.execute("SELECT id, current_path FROM security_targets"): + checkout = Path(target["current_path"]) + if not checkout.exists(): + continue + verified = scan_history._verified_target_metadata(connection, target["id"], checkout) + if verified is None: + replaced_targets.append(target["id"]) + elif verified[0] is not None: + epoch_start = scan_history._ownership_epoch_start( + connection, target["id"], verified[0] + ) + if epoch_start is not None: + transitioned_targets.append(target["id"]) + ownership_epochs.append((target["id"], epoch_start)) + if replaced_targets: + placeholders = ", ".join("?" for _ in replaced_targets) + current_owner_only += ( + f" AND (scans.target_id IS NULL OR scans.target_id NOT IN ({placeholders}))" + ) + target_values.extend(replaced_targets) + if transitioned_targets: + placeholders = ", ".join("?" for _ in transitioned_targets) + current_owner_only += ( + " AND (scans.target_id IS NULL " + f"OR scans.target_id NOT IN ({placeholders}) " + "OR scans.target_device IS NOT NULL OR scans.target_inode IS NOT NULL)" + ) + target_values.extend(transitioned_targets) + for target_id, epoch_start in ownership_epochs: + current_owner_only += " AND (scans.target_id IS NOT ? OR scans.rowid > ?)" + target_values.extend((target_id, epoch_start)) + completed_scans_by_target: dict[str, list[sqlite3.Row]] = {} + for scan in connection.execute( + f""" + SELECT scans.*, COALESCE(targets.id, scans.target_path) AS indexed_target_id + FROM scans + LEFT JOIN security_targets AS targets ON targets.id = scans.target_id + WHERE scans.status = 'complete' AND scans.seal_manifest_digest IS NOT NULL + {target_filter} {repository_filter} {current_owner_only} + ORDER BY scans.started_at DESC, scans.id DESC + """, + target_values, + ): + completed_scans_by_target.setdefault(scan["indexed_target_id"], []).append(scan) + + coverage_by_scan_id: dict[str, dict[str, Any] | None] = {} + if query: + connection.create_function("codex_security_casefold", 1, str.casefold, deterministic=True) + secondary_location_match = ( + "EXISTS (" + "SELECT 1 FROM finding_locations AS searched_locations " + "WHERE searched_locations.occurrence_id = selected_findings.occurrence_id " + "AND instr(codex_security_casefold(searched_locations.relative_path), ?) > 0)" + if query + else "0" + ) + rows = connection.execute( + f""" WITH ranked_findings AS ( SELECT occurrences.id AS occurrence_id, @@ -80,27 +223,32 @@ def _indexed_findings(connection: sqlite3.Connection) -> Iterator[sqlite3.Row]: occurrences.severity, occurrences.created_at, scans.id AS scan_id, - scans.target_id, - targets.current_path AS target_path, + scans.started_at AS scan_started_at, + targets.id AS target_id, + COALESCE(targets.id, scans.target_path) AS indexed_target_id, + COALESCE(targets.current_path, scans.target_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 + PARTITION BY COALESCE(targets.id, scans.target_path), 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 + PARTITION BY COALESCE(targets.id, scans.target_path), occurrences.finding_id + ORDER BY scans.started_at DESC, scans.id DESC, + 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 security_targets AS targets ON targets.id = scans.target_id LEFT JOIN finding_triage AS triage ON triage.occurrence_id = occurrences.id + WHERE 1 = 1 {target_filter} {repository_filter} {current_owner_only} ) SELECT selected_findings.*, occurrences.title, occurrences.summary, + {secondary_location_match} AS secondary_location_match, ( SELECT locations.relative_path FROM finding_locations AS locations @@ -127,12 +275,61 @@ def _indexed_findings(connection: sqlite3.Connection) -> Iterator[sqlite3.Row]: selected_findings.created_at DESC, selected_findings.occurrence_id """, + [*target_values, *([query] if query else [])], ) + for row in rows: + resolved = False + for scan in completed_scans_by_target.get(row["indexed_target_id"], ()): + if (scan["started_at"], scan["id"]) <= ( + row["scan_started_at"], + row["scan_id"], + ): + break + if scan["id"] not in coverage_by_scan_id: + try: + coverage_by_scan_id[scan["id"]] = read_coverage(scan) + except SystemExit as error: + message = str(error) + if message == ( + "Scan directory must be an existing canonical non-symlink directory." + ): + try: + Path(scan["scan_dir"]).lstat() + except FileNotFoundError: + coverage_by_scan_id[scan["id"]] = None + else: + raise + elif message.startswith("missing required contract artifact: ") or message.endswith( + ": expected a regular file inside the scan directory." + ): + coverage_by_scan_id[scan["id"]] = None + else: + raise + coverage = coverage_by_scan_id[scan["id"]] + if coverage is None: + continue + comparable_scan = ( + scan + if scan["target_id"] == row["indexed_target_id"] + else {**dict(scan), "target_id": row["indexed_target_id"]} + ) + if scan_history.scan_covers_path( + comparable_scan, + target_id=row["indexed_target_id"], + path=row["location_path"], + coverage=coverage, + ): + resolved = True + break + if not resolved: + yield row def list_repositories( connection: sqlite3.Connection, args: argparse.Namespace | None = None, + *, + read_coverage: Callable[[sqlite3.Row], dict[str, Any]], ) -> dict[str, Any]: scans = scan_history.list_scans(connection)["scans"] scans_by_id = {scan["scanId"]: scan for scan in scans} @@ -148,7 +345,9 @@ def list_repositories( latest_scan_by_target.setdefault(row["target_id"], scans_by_id[row["id"]]) open_findings_by_target = Counter( - row["target_id"] for row in _indexed_findings(connection) if row["status"] == "open" + row["target_id"] + for row in _active_findings(connection, read_coverage) + if row["status"] == "open" ) targets = {row["id"]: row for row in connection.execute("SELECT * FROM security_targets")} repositories = [ diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py index 323fdbe7..a5455bd5 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py @@ -12,6 +12,10 @@ # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) +from filesystem_identity import ( + serialize_filesystem_identity, + stored_filesystem_identity_matches, +) from report_projection import SEVERITY_ORDER from workbench_constants import FINDINGS_PAGE_MAX from workbench_scan_usage import stored_scan_cost_fields @@ -22,29 +26,92 @@ def _same_repository( before: sqlite3.Row, after: sqlite3.Row, *, - after_identity: tuple[str | None, tuple[str, str] | None] | None = None, + before_target_path: str | None = None, + after_target_path: str | None = None, + after_git_directory: str | None = None, + require_ownership: bool = False, ) -> bool: - if before["target_id"] == after["target_id"]: + before_target_id = before["target_id"] + after_target_id = after["target_id"] + for scan in (before, after): + if not scan["target_id"] or not all( + field in scan.keys() for field in ("target_device", "target_inode") + ): + if require_ownership: + return False + continue + device, inode = scan["target_device"], scan["target_inode"] + if device is None and inode is None: + if require_ownership: + return False + continue + target = ( + before_target_path + if scan is before and before_target_path is not None + else after_target_path + if scan is after and after_target_path is not None + else scan["target_path"] + ) + try: + metadata = Path(target).stat() + except OSError: + return False + if not stored_filesystem_identity_matches( + device, metadata.st_dev + ) or not stored_filesystem_identity_matches(inode, metadata.st_ino): + return False + if before_target_id and before_target_id == after_target_id: + fields = ("target_device", "target_inode") + if all(field in row.keys() for row in (before, after) for field in fields): + before_identity = tuple(before[field] for field in fields) + after_identity = tuple(after[field] for field in fields) + if any(value is not None for value in (*before_identity, *after_identity)): + return before_identity == after_identity and None not in before_identity return True - before_target = Path(before["target_path"]) - after_target = Path(after["target_path"]) + before_target = Path(before["target_path"] if before_target_path is None else before_target_path) + after_target = Path(after["target_path"] if after_target_path is None else after_target_path) + if before_target.resolve() == after_target.resolve(): + return not before_target_id and not after_target_id before_git_dir = git_output( before_target, "rev-parse", "--path-format=absolute", "--git-common-dir" ) - after_git_dir = ( - git_output(after_target, "rev-parse", "--path-format=absolute", "--git-common-dir") - if after_identity is None - else after_identity[0] + after_git_dir = after_git_directory or git_output( + after_target, "rev-parse", "--path-format=absolute", "--git-common-dir" ) - if ( - before_git_dir is not None - and after_git_dir is not None - and Path(before_git_dir).resolve() == Path(after_git_dir).resolve() + if before_git_dir is None or after_git_dir is None: + return False + if Path(before_git_dir).resolve() != Path(after_git_dir).resolve(): + if not before_target_id or not after_target_id: + return False + if not all( + field in scan.keys() and scan[field] is not None + for scan in (before, after) + for field in ("target_device", "target_inode") + ): + return False + before_origin = _repository_origin(before_target) + return before_origin is not None and before_origin == _repository_origin(after_target) + before_worktree = git_output(before_target, "rev-parse", "--show-toplevel") + after_worktree = git_output(after_target, "rev-parse", "--show-toplevel") + registered_worktrees = git_output(before_target, "worktree", "list", "--porcelain", "-z") + if before_worktree is None or after_worktree is None or registered_worktrees is None: + return False + before_worktree_path = Path(before_worktree).resolve() + after_worktree_path = Path(after_worktree).resolve() + if before_worktree_path == after_worktree_path and not ( + before_target.resolve().is_relative_to(after_target.resolve()) + or after_target.resolve().is_relative_to(before_target.resolve()) ): - return True - before_origin = _repository_origin(before_target) - return before_origin is not None and before_origin == ( - _repository_origin(after_target) if after_identity is None else after_identity[1] + return False + registered_paths = { + Path(record.removeprefix("worktree ")).resolve() + for record in registered_worktrees.split("\0") + if record.startswith("worktree ") + } + return ( + before_target.resolve().is_relative_to(before_worktree_path) + and after_target.resolve().is_relative_to(after_worktree_path) + and {before_worktree_path, after_worktree_path} <= registered_paths ) @@ -75,38 +142,348 @@ def _repository_origin(target: Path) -> tuple[str, str] | None: return (host.lower(), path) if host and path else None -def list_scans( - connection: sqlite3.Connection, args: argparse.Namespace | None = None -) -> dict[str, Any]: +def _requested_repository( + connection: sqlite3.Connection, repository: Path +) -> tuple[sqlite3.Row, str | None]: + requested = connection.execute( + """ + SELECT COALESCE((SELECT id FROM security_targets WHERE current_path = ?), '') AS target_id, + ? AS target_path + """, + (str(repository), str(repository)), + ).fetchone() + target_id = requested["target_id"] + if not target_id: + return requested, None + recorded = connection.execute( + """ + SELECT target_path, target_device, target_inode, target_revision + FROM scans + WHERE target_id = ? AND target_device IS NOT NULL AND target_inode IS NOT NULL + ORDER BY rowid DESC + LIMIT 1 + """, + (target_id,), + ).fetchone() + if recorded is None: + return requested, None + try: + metadata = repository.stat() + except OSError: + metadata = None + if ( + metadata is not None + and stored_filesystem_identity_matches(recorded["target_device"], metadata.st_dev) + and stored_filesystem_identity_matches(recorded["target_inode"], metadata.st_ino) + ): + return ( + connection.execute( + "SELECT ? AS target_id, ? AS target_path, ? AS target_device, ? AS target_inode", + ( + target_id, + str(repository), + recorded["target_device"], + recorded["target_inode"], + ), + ).fetchone(), + None, + ) + return ( + connection.execute( + "SELECT '' AS target_id, ? AS target_path", (str(repository),) + ).fetchone(), + target_id, + ) + + +def _verified_target_metadata( + connection: sqlite3.Connection, target_id: str, repository: Path +) -> tuple[os.stat_result | None, bool] | None: + requested, _ = _requested_repository(connection, repository) + if requested["target_id"] != target_id: + return None + try: + metadata = repository.stat() + except OSError: + return None, False + recorded = connection.execute( + """ + SELECT 1 + FROM scans + WHERE target_id = ? AND target_device = ? AND target_inode = ? + LIMIT 1 + """, + ( + target_id, + serialize_filesystem_identity(metadata.st_dev), + serialize_filesystem_identity(metadata.st_ino), + ), + ).fetchone() + return metadata, recorded is not None + + +def _ownership_epoch_start( + connection: sqlite3.Connection, target_id: str, metadata: os.stat_result +) -> int | None: + previous_owner = connection.execute( + """ + SELECT rowid AS ownership_sequence + FROM scans + WHERE target_id = ? AND target_device IS NOT NULL AND target_inode IS NOT NULL + AND (target_device != ? OR target_inode != ?) + ORDER BY rowid DESC + LIMIT 1 + """, + ( + target_id, + serialize_filesystem_identity(metadata.st_dev), + serialize_filesystem_identity(metadata.st_ino), + ), + ).fetchone() + return previous_owner["ownership_sequence"] if previous_owner is not None else None + + +def repository_scan_scope( + connection: sqlite3.Connection, repository: str | Path +) -> tuple[list[str], list[Any], list[str], list[str]]: clauses: list[str] = [] values: list[Any] = [] - if args is not None and args.repository: - repository = Path(args.repository).expanduser().resolve() - requested_repository = connection.execute( - """ - SELECT COALESCE((SELECT id FROM security_targets WHERE current_path = ?), '') AS target_id, - ? AS target_path - """, - (str(repository), str(repository)), - ).fetchone() - requested_identity = ( - git_output(repository, "rev-parse", "--path-format=absolute", "--git-common-dir"), - _repository_origin(repository), + related_target_ids: list[str] = [] + repository_paths: list[str] = [] + if repository: + repository = Path(repository).expanduser().resolve() + requested_repository, replaced_target_id = _requested_repository(connection, repository) + requested_target_id = requested_repository["target_id"] + verified_targets: dict[str, tuple[os.stat_result | None, bool]] = {} + if requested_target_id: + requested_metadata = _verified_target_metadata( + connection, requested_target_id, repository + ) + if requested_metadata is None: + replaced_target_id = requested_target_id + requested_repository = connection.execute( + "SELECT '' AS target_id, ? AS target_path", (str(repository),) + ).fetchone() + requested_target_id = "" + else: + related_target_ids.append(requested_target_id) + verified_targets[requested_target_id] = requested_metadata + repository_root = git_output(repository, "rev-parse", "--show-toplevel") + checkout_boundary = ( + Path(repository_root).resolve() if repository_root is not None else None + ) + if checkout_boundary is None: + for candidate in (repository, *repository.parents): + marker = candidate / ".git" + if marker.is_dir() or marker.is_file() or marker.is_symlink(): + checkout_boundary = candidate + break + repository_paths = [str(repository)] + registered_repository = ( + connection.execute( + "SELECT 1 FROM scans WHERE target_id = ? LIMIT 1", + (requested_target_id,), + ).fetchone() + if requested_target_id + else connection.execute( + "SELECT 1 FROM scans WHERE target_path = ? AND target_id IS NULL LIMIT 1", + (str(repository),), + ).fetchone() ) - related_target_ids = [ - target["target_id"] - for target in connection.execute( - "SELECT id AS target_id, current_path AS target_path FROM security_targets" + registered_parent = None + if registered_repository is None: + for parent in repository.parents: + if checkout_boundary is not None and not parent.is_relative_to(checkout_boundary): + break + repository_paths.append(str(parent)) + registered_parent = connection.execute( + """ + SELECT scans.target_id + FROM scans + LEFT JOIN security_targets AS owner ON owner.current_path = ? + WHERE scans.target_id = owner.id + OR (scans.target_path = ? AND owner.id IS NULL) + LIMIT 1 + """, + (str(parent), str(parent)), + ).fetchone() + if registered_parent is not None: + if registered_parent["target_id"] is not None: + parent_target_id = registered_parent["target_id"] + parent_metadata = _verified_target_metadata( + connection, parent_target_id, parent + ) + if parent_metadata is None: + repository_paths.pop() + registered_parent = None + continue + related_target_ids.append(parent_target_id) + verified_targets[parent_target_id] = parent_metadata + break + if registered_repository is None and registered_parent is None: + requested_git_directory = git_output( + repository, "rev-parse", "--path-format=absolute", "--git-common-dir" ) - if _same_repository(target, requested_repository, after_identity=requested_identity) + repository_prefix = str(repository).rstrip(os.sep) + os.sep + for scan in connection.execute( + "SELECT target_id, target_path FROM scans WHERE substr(target_path, 1, ?) = ?", + (len(repository_prefix), repository_prefix), + ): + target_path = Path(scan["target_path"]) + if target_path == repository or not target_path.is_relative_to(repository): + continue + if requested_git_directory is not None: + if scan["target_id"] is not None or not _same_repository( + scan, requested_repository, after_git_directory=requested_git_directory + ): + continue + elif checkout_boundary is not None: + continue + else: + if scan["target_id"] is not None: + owner = connection.execute( + "SELECT current_path FROM security_targets WHERE id = ?", + (scan["target_id"],), + ).fetchone() + if owner is None or Path(owner["current_path"]).resolve() != target_path: + continue + descendant_metadata = _verified_target_metadata( + connection, scan["target_id"], target_path + ) + if descendant_metadata is None: + continue + verified_targets[scan["target_id"]] = descendant_metadata + candidate = target_path + while candidate != repository: + marker = candidate / ".git" + if marker.is_dir() or marker.is_file() or marker.is_symlink(): + break + candidate = candidate.parent + if candidate != repository: + continue + repository_paths.append(str(target_path)) + if requested_git_directory is not None: + for target in connection.execute( + "SELECT id AS target_id, current_path AS target_path FROM security_targets" + ): + if target["target_id"] == requested_target_id: + continue + target_metadata = _verified_target_metadata( + connection, target["target_id"], Path(target["target_path"]) + ) + if target_metadata is None: + continue + metadata, recorded = target_metadata + verified_target = connection.execute( + "SELECT ? AS target_id, ? AS target_path, ? AS target_device, ? AS target_inode", + ( + target["target_id"], + target["target_path"], + serialize_filesystem_identity(metadata.st_dev) + if metadata is not None and recorded + else None, + serialize_filesystem_identity(metadata.st_ino) + if metadata is not None and recorded + else None, + ), + ).fetchone() + if not _same_repository( + verified_target, + requested_repository, + after_git_directory=requested_git_directory, + ): + continue + related_target_ids.append(target["target_id"]) + verified_targets[target["target_id"]] = target_metadata + checkout_target = ( + connection.execute( + "SELECT id FROM security_targets WHERE current_path = ?", + (str(checkout_boundary),), + ).fetchone() + if checkout_boundary is not None + else None + ) + checkout_metadata = ( + _verified_target_metadata(connection, checkout_target["id"], checkout_boundary) + if checkout_target is not None and checkout_boundary is not None + else None + ) + if checkout_metadata is not None and checkout_target is not None: + related_target_ids.append(checkout_target["id"]) + verified_targets[checkout_target["id"]] = checkout_metadata + registered_worktrees = git_output( + checkout_boundary, "worktree", "list", "--porcelain", "-z" + ) + for record in (registered_worktrees or "").split("\0"): + if not record.startswith("worktree "): + continue + worktree = Path(record.removeprefix("worktree ")).resolve() + if worktree == checkout_boundary: + continue + related = connection.execute( + "SELECT id FROM security_targets WHERE current_path = ?", + (str(worktree),), + ).fetchone() + if related is None: + continue + target_metadata = _verified_target_metadata(connection, related["id"], worktree) + if target_metadata is None: + continue + related_target_ids.append(related["id"]) + verified_targets[related["id"]] = target_metadata + repository_paths = list(dict.fromkeys(repository_paths)) + related_target_ids = list(dict.fromkeys(related_target_ids)) + repository_placeholders = ", ".join("?" for _ in repository_paths) + repository_clauses = [ + f"scans.target_path IN ({repository_placeholders}) " + "AND NOT EXISTS (" + "SELECT 1 FROM security_targets AS path_owner " + "WHERE path_owner.current_path = scans.target_path " + "AND path_owner.id IS NOT scans.target_id)" ] - repository_clauses = ["scans.target_path = ?"] - values.append(str(repository)) + values.extend(repository_paths) + if replaced_target_id is not None: + repository_clauses[0] += " AND scans.target_id IS NOT ?" + values.append(replaced_target_id) if related_target_ids: placeholders = ", ".join("?" for _ in related_target_ids) repository_clauses.append(f"scans.target_id IN ({placeholders})") values.extend(related_target_ids) clauses.append(f"({' OR '.join(repository_clauses)})") + for target_id, (metadata, recorded) in verified_targets.items(): + if metadata is None or not recorded: + continue + epoch_start = _ownership_epoch_start(connection, target_id, metadata) + legacy_history = ( + "OR (scans.target_inode IS NULL AND scans.target_device IS NULL) " + if epoch_start is None + else "" + ) + clauses.append( + "(scans.target_id IS NOT ? " + f"{legacy_history}OR (scans.target_inode = ? AND scans.target_device = ?))" + ) + values.extend( + ( + target_id, + serialize_filesystem_identity(metadata.st_ino), + serialize_filesystem_identity(metadata.st_dev), + ) + ) + if epoch_start is not None: + clauses.append("(scans.target_id IS NOT ? OR scans.rowid > ?)") + values.extend((target_id, epoch_start)) + return clauses, values, related_target_ids, repository_paths + + +def list_scans( + connection: sqlite3.Connection, args: argparse.Namespace | None = None +) -> dict[str, Any]: + clauses, values, related_target_ids, repository_paths = ( + repository_scan_scope(connection, args.repository) + if args is not None and args.repository + else ([], [], [], []) + ) if args is not None and args.scan_root: scan_root = str(Path(args.scan_root).expanduser().resolve()) prefix = scan_root.rstrip(os.sep) + os.sep @@ -144,6 +521,7 @@ def list_scans( f""" SELECT scans.*, + targets.current_path AS current_target_path, progress.reportable_findings_count, progress.scope_file_count, progress.review_items_completed, @@ -156,6 +534,7 @@ def list_scans( ) AS finding_count FROM scans JOIN scan_progress AS progress ON progress.scan_id = scans.id + LEFT JOIN security_targets AS targets ON targets.id = scans.target_id {where} ORDER BY CASE WHEN scans.status = 'running' AND scans.canceled_at IS NULL THEN 0 ELSE 1 END, @@ -196,6 +575,20 @@ def list_scans( "startedAt": row["started_at"], "targetId": row["target_id"], "targetPath": row["target_path"], + **( + {"relatedCheckout": True} + if args is not None + and args.repository + and row["target_id"] in related_target_ids + and row["target_path"] not in repository_paths + else {} + ), + **( + {"currentTargetPath": row["current_target_path"]} + if row["current_target_path"] is not None + and row["current_target_path"] != row["target_path"] + else {} + ), "targetRevision": row["target_revision"], "targetSummary": row["target_summary"], "updatedAt": max(row["updated_at"], row["progress_updated_at"]), @@ -227,19 +620,54 @@ def list_unmatched_scan_pairs( read_coverage: Callable[[sqlite3.Row], dict[str, Any]], ) -> dict[str, Any]: repository = Path(args.repository).expanduser().resolve() - requested = connection.execute( - """ - SELECT COALESCE((SELECT id FROM security_targets WHERE current_path = ?), '') AS target_id, - ? AS target_path - """, - (str(repository), str(repository)), - ).fetchone() + requested, _replaced_target_id = _requested_repository(connection, repository) + try: + metadata = repository.stat() + except OSError: + metadata = None + verified_targets: dict[str, tuple[os.stat_result | None, bool] | None] = {} + ownership_epochs: dict[str, int | None] = {} + + def belongs_to_current_owner(scan: sqlite3.Row) -> bool: + target_id = scan["target_id"] + if not target_id: + return False + if target_id not in verified_targets: + target = connection.execute( + "SELECT current_path FROM security_targets WHERE id = ?", (target_id,) + ).fetchone() + verified_targets[target_id] = ( + None + if target is None + else _verified_target_metadata( + connection, target_id, Path(target["current_path"]) + ) + ) + target_metadata = verified_targets[target_id] + if target_metadata is None or target_metadata[0] is None or not target_metadata[1]: + return False + if target_id not in ownership_epochs: + ownership_epochs[target_id] = _ownership_epoch_start( + connection, target_id, target_metadata[0] + ) + epoch_start = ownership_epochs[target_id] + return stored_filesystem_identity_matches( + scan["target_device"], target_metadata[0].st_dev + ) and stored_filesystem_identity_matches( + scan["target_inode"], target_metadata[0].st_ino + ) and (epoch_start is None or scan["ownership_sequence"] > epoch_start) + selected = [ scan for scan in connection.execute( - "SELECT * FROM scans WHERE status = 'complete' ORDER BY started_at, id" + "SELECT scans.*, scans.rowid AS ownership_sequence, " + "targets.current_path AS current_target_path " + "FROM scans LEFT JOIN security_targets AS targets ON targets.id = scans.target_id " + "WHERE scans.status = 'complete' ORDER BY scans.started_at, scans.id" ) - if Path(scan["target_path"]).resolve() == repository or _same_repository(scan, requested) + if metadata is not None + and _same_repository(scan, requested, before_target_path=scan["current_target_path"]) + and belongs_to_current_owner(scan) ] available = [] @@ -295,6 +723,40 @@ def list_unmatched_scan_pairs( } +def _same_registered_repository( + connection: sqlite3.Connection, before: sqlite3.Row, after: sqlite3.Row +) -> bool: + paths = [] + for scan in (before, after): + target = connection.execute( + "SELECT current_path FROM security_targets WHERE id = ?", + (scan["target_id"],), + ).fetchone() + if target is None: + return False + if "started_at" in scan.keys() and "id" in scan.keys(): + try: + metadata = Path(target["current_path"]).stat() + except OSError: + return False + epoch_start = _ownership_epoch_start(connection, scan["target_id"], metadata) + if epoch_start is not None: + sequence = connection.execute( + "SELECT rowid AS ownership_sequence FROM scans WHERE id = ?", + (scan["id"],), + ).fetchone() + if sequence is None or sequence["ownership_sequence"] <= epoch_start: + return False + paths.append(target["current_path"]) + return _same_repository( + before, + after, + before_target_path=paths[0], + after_target_path=paths[1], + require_ownership=True, + ) + + def compare_scans( connection: sqlite3.Connection, args: argparse.Namespace, @@ -311,7 +773,7 @@ def compare_scans( raise SystemExit("Select two different scans to compare.") if before["status"] != "complete" or after["status"] != "complete": raise SystemExit("Only completed scans can be compared.") - if not _same_repository(before, after): + if not _same_registered_repository(connection, before, after): raise SystemExit("Semantic scan comparisons require the same repository target.") cached = connection.execute( "SELECT result_json FROM scan_comparisons WHERE before_scan_id = ? AND after_scan_id = ?", @@ -451,7 +913,7 @@ def save_scan_comparison( raise SystemExit("Select two different scans to compare.") if before["status"] != "complete" or after["status"] != "complete": raise SystemExit("Only completed scans can be compared.") - if not _same_repository(before, after): + if not _same_registered_repository(connection, before, after): raise SystemExit("Semantic scan comparisons require the same repository target.") read_coverage(after) before_findings = _scan_findings(connection, before["id"]) @@ -650,6 +1112,8 @@ def finding_occurrence_rows( severity: str | None = None, status: str | None = None, ) -> list[sqlite3.Row]: + if query is not None and query.strip(): + connection.create_function("codex_security_casefold", 1, str.casefold, deterministic=True) conditions, values = finding_occurrence_conditions( scan_id, query=query, severity=severity, status=status ) @@ -704,12 +1168,12 @@ def finding_occurrence_conditions( search = query.strip().casefold() if search: conditions.append( - "(instr(lower(occurrences.title), ?) > 0 " - "OR instr(lower(occurrences.summary), ?) > 0 " + "(instr(codex_security_casefold(occurrences.title), ?) > 0 " + "OR instr(codex_security_casefold(occurrences.summary), ?) > 0 " "OR EXISTS (" "SELECT 1 FROM finding_locations AS locations " "WHERE locations.occurrence_id = occurrences.id " - "AND instr(lower(locations.relative_path), ?) > 0))" + "AND instr(codex_security_casefold(locations.relative_path), ?) > 0))" ) values.extend((search, search, search)) return " AND ".join(conditions), values diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index bd19fb88..4539bd0e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -493,8 +493,12 @@ def require_remediation_target(value: str) -> Path: return stored -def require_scan_target_identity(scan: sqlite3.Row) -> Path: - target = require_remediation_target(scan["target_path"]) +def require_scan_target_identity( + scan: sqlite3.Row, *, target_path: str | None = None +) -> Path: + target = require_remediation_target( + scan["target_path"] if target_path is None else target_path + ) expected_inode = scan["target_inode"] if expected_inode is None: raise SystemExit( @@ -507,7 +511,10 @@ def require_scan_target_identity(scan: sqlite3.Row) -> Path: raise SystemExit( "Remediation is unavailable because the selected checkout is no longer accessible." ) from exc - if not stored_filesystem_identity_matches(expected_inode, metadata.st_ino): + if not stored_filesystem_identity_matches(expected_inode, metadata.st_ino) or ( + target_path is not None + and not stored_filesystem_identity_matches(scan["target_device"], metadata.st_dev) + ): raise SystemExit( "Remediation is unavailable because the selected checkout path was replaced. " "Start a new scan." diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 1a2a3f4a..8470c2e7 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -192,6 +192,12 @@ const VALUE_OPTIONS = new Set([ "--token-limit", "--token-offset", "--scan-root", + "--scan", + "--query", + "--severity", + "--status", + "--offset", + "--limit", "--reason", ]); const PROVIDER_OPTION = z @@ -775,6 +781,7 @@ export async function main( dependencies.environment["NO_COLOR"] === undefined && dependencies.environment["TERM"] !== "dumb", now: dependencies.now(), + currentDirectory: dependencies.currentDirectory(), repository: settings.repository, scanRoot: settings.scanRoot, showLinkedFindings: settings.showLinkedFindings, @@ -782,48 +789,185 @@ export async function main( return result; }; const findingFeedback = Cli.create("findings", { - description: "Review and manage saved Codex Security findings.", - }).command("false-positive", { - description: "Mark a finding as a false positive for future scans.", - destructive: true, - mcp: false, - args: z.object({ - occurrenceId: z - .string() - .trim() - .min(1) - .max(256) - .describe("Finding occurrence identifier."), - }), - options: z.object({ - reason: z - .string() - .trim() - .min(1, "--reason must not be empty.") - .max(2_400, "--reason must not exceed 2400 characters.") - .describe("Explanation for why the finding is a false positive."), - }), - output: z.record(z.string(), z.unknown()).optional(), - async run({ args, options }) { - return await history([ - "set-finding-triage", - "--occurrence-id", - args.occurrenceId, - "--status", - "closed", - "--close-reason", - "false_positive", - "--note", - options.reason, - ]); - }, - }); + description: "Browse, inspect, and manage findings from saved scans.", + }) + .command("list", { + description: + "List saved findings for this repository or one previous scan.", + mcp: false, + options: z + .object({ + scan: optionValue("--scan") + .optional() + .describe("List every finding from a saved scan ID or prefix."), + allRepositories: z + .boolean() + .default(false) + .describe("Include active findings from every saved repository."), + query: optionValue("--query") + .optional() + .describe("Search finding titles, summaries, or source paths."), + severity: z + .enum(DISPLAY_SEVERITIES) + .optional() + .describe("Include only findings with this severity."), + status: z + .enum(["open", "closed"]) + .optional() + .describe("Include only findings with this triage status."), + offset: z + .number() + .int() + .nonnegative() + .default(0) + .describe("Skip this many findings when requesting another page."), + limit: z + .number() + .int() + .min(1) + .max(20) + .default(20) + .describe("Maximum findings per page (1-20; default: 20)."), + }) + .refine((options) => !(options.scan && options.allRepositories), { + message: "--scan cannot be combined with --all-repositories.", + }), + output: z.record(z.string(), z.unknown()).optional(), + async run({ format, options }) { + const filters = [ + ...(options.query === undefined ? [] : ["--query", options.query]), + ...(options.severity === undefined + ? [] + : ["--severity", options.severity]), + ...(options.status === undefined + ? options.scan === undefined + ? ["--status", "open"] + : [] + : ["--status", options.status]), + "--offset", + String(options.offset), + "--limit", + String(options.limit), + ]; + if (options.scan !== undefined) { + return presentHistory( + await history( + ["list-findings", "--scan-id", options.scan, ...filters], + ({ findingsPage }) => findingsPage as JsonObject, + ), + "findings", + format, + ); + } + + const repository = options.allRepositories + ? undefined + : dependencies.currentDirectory(); + return presentHistory( + await history([ + "list-global-findings", + ...(repository === undefined ? [] : ["--repository", repository]), + ...filters, + ]), + "findings", + format, + { repository }, + ); + }, + }) + .command("show", { + description: + "Show one finding, its occurrence ID, and saved links to previous scans.", + mcp: false, + args: z.object({ + occurrenceId: z + .string() + .trim() + .min(1) + .max(256) + .describe("Finding occurrence ID from findings list."), + }), + output: z.record(z.string(), z.unknown()).optional(), + async run({ args, format }) { + return presentHistory( + await history( + ["get-finding", "--occurrence-id", args.occurrenceId], + ({ scan }) => { + const result = scan as JsonObject; + const scanDir = result["scanDir"]; + const scanId = result["scanId"]; + const targetPath = result["targetPath"]; + const currentTargetPath = result["currentTargetPath"]; + const finding = (result["findings"] as JsonObject[]).find( + (entry) => entry["occurrenceId"] === args.occurrenceId, + ); + if ( + finding === undefined || + typeof scanId !== "string" || + typeof targetPath !== "string" + ) { + throw new CodexSecurityError( + "The selected finding was not returned by the workbench.", + ); + } + return { + ...finding, + ...(typeof scanDir === "string" ? { scanDir } : {}), + scanId, + targetPath, + ...(typeof currentTargetPath === "string" + ? { currentTargetPath } + : {}), + }; + }, + ), + "finding", + format, + { showLinkedFindings: true }, + ); + }, + }) + .command("false-positive", { + description: "Mark a finding as a false positive for future scans.", + destructive: true, + mcp: false, + args: z.object({ + occurrenceId: z + .string() + .trim() + .min(1) + .max(256) + .describe("Finding occurrence ID from findings list."), + }), + options: z.object({ + reason: z + .string() + .trim() + .min(1, "--reason must not be empty.") + .max(2_400, "--reason must not exceed 2400 characters.") + .describe("Explanation for why the finding is a false positive."), + }), + output: z.record(z.string(), z.unknown()).optional(), + async run({ args, options }) { + return await history([ + "set-finding-triage", + "--occurrence-id", + args.occurrenceId, + "--status", + "closed", + "--close-reason", + "false_positive", + "--note", + options.reason, + ]); + }, + }); const scanHistory = Cli.create("scans", { description: - "List, inspect, rerun, match, and compare saved Codex Security scans.", + "Browse previous scans, inspect findings, and compare changes over time.", }) .command("list", { - description: "List saved scans for a repository or scan root.", + description: "List previous scans for a repository or scan root.", mcp: false, args: z.object({ repository: z @@ -865,24 +1009,63 @@ export async function main( }, }) .command("show", { - description: "Show the results and saved configuration for a scan.", + description: + "Show findings and configuration for a saved or latest scan.", mcp: false, args: z.object({ scanId: z .string() .min(1) - .describe("Saved scan identifier or unique prefix."), + .optional() + .describe( + "Saved scan ID or prefix (default: latest completed scan).", + ), }), options: z.object({ showLinkedFindings: z .boolean() .default(false) - .describe("Show findings linked across previous scans."), + .describe( + "Show saved links; run scans compare or scans match --all first.", + ), }), output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, options }) { + let scanId = args.scanId; + if (scanId === undefined || scanId === "latest") { + const repository = dependencies.currentDirectory(); + const latest = await history( + ["list-scans", "--repository", repository], + ({ scans }) => { + const scan = (scans as JsonObject[]) + .filter( + (entry) => + (entry["progress"] as JsonObject | undefined)?.[ + "status" + ] === "complete", + ) + .sort((left, right) => { + const leftCompleted = String( + left["completedAt"] ?? left["startedAt"] ?? "", + ); + const rightCompleted = String( + right["completedAt"] ?? right["startedAt"] ?? "", + ); + return rightCompleted.localeCompare(leftCompleted); + })[0]; + if (typeof scan?.["scanId"] !== "string") { + throw new CodexSecurityError( + `No completed scans found for ${repository}. Run 'codex-security scan .' first.`, + ); + } + return { scanId: scan["scanId"] }; + }, + ); + if (latest === undefined) return undefined; + scanId = latest["scanId"] as string; + } return presentHistory( - await history(["get-scan", "--scan-id", args.scanId], (value) => { + await history(["get-scan", "--scan-id", scanId], (value) => { const { scan, recipe, parentScanId } = value; return { ...(scan as JsonObject), @@ -943,7 +1126,7 @@ export async function main( }, }) .command("match", { - description: "Match findings by root cause across saved scans.", + description: "Use Codex to create and save cross-scan finding links.", destructive: true, mcp: false, args: z.object({ @@ -962,7 +1145,9 @@ export async function main( all: z .boolean() .default(false) - .describe("Match all completed scans of the current repository."), + .describe( + "Use Codex to link all completed scans of this repository.", + ), force: z .boolean() .default(false) @@ -991,7 +1176,8 @@ export async function main( }, }) .command("compare", { - description: "Match and compare findings and coverage between scans.", + description: + "Show new, persisting, resolved, or unknown findings; matching uses Codex.", destructive: true, mcp: false, args: z.object({ @@ -1008,7 +1194,8 @@ export async function main( }, }); const cli = Cli.create("codex-security", { - description: "Run, validate, patch, and export Codex Security findings.", + description: + "Run security scans, review previous findings, validate, patch, and export.", version: VERSION, mcp: { command: "npx --yes @openai/codex-security --mcp", @@ -1824,7 +2011,7 @@ function defaultScansList(argv: readonly string[]): readonly string[] { }); if ( commandIndex < 0 || - argv[commandIndex] !== "scans" || + !["scans", "findings"].includes(argv[commandIndex]!) || argv.includes("--help") || argv.includes("-h") ) { @@ -2124,7 +2311,9 @@ function validateCliArguments( command !== "validate" && command !== "patch" && positionals.length > - (command === "logout" || command === "info" + (command === "logout" || + command === "info" || + (command === "findings" && subcommand === "list") ? 0 : subcommand === "compare" || subcommand === "match" ? 2 diff --git a/sdk/typescript/src/scan-history-renderer.ts b/sdk/typescript/src/scan-history-renderer.ts index f8d2fe6d..0238f8f6 100644 --- a/sdk/typescript/src/scan-history-renderer.ts +++ b/sdk/typescript/src/scan-history-renderer.ts @@ -1,11 +1,18 @@ -import { basename, relative } from "node:path"; +import { basename, join, relative } from "node:path"; import type { JsonObject } from "./config.js"; -export type HistoryCommand = "list" | "show" | "compare" | "match-all"; +export type HistoryCommand = + | "list" + | "show" + | "compare" + | "match-all" + | "findings" + | "finding"; type RendererOptions = { columns?: number; color?: boolean; now?: number; + currentDirectory?: string; repository?: string; scanRoot?: string; showLinkedFindings?: boolean; @@ -67,6 +74,8 @@ export function renderScanHistory( show: "SCAN DETAILS", compare: "SCAN COMPARISON", "match-all": "MATCH RESULTS", + findings: "SAVED FINDINGS", + finding: "FINDING DETAILS", }; const lines = [ "", @@ -109,15 +118,46 @@ export function renderScanHistory( ? ` in ${clean(knownScanIds[0]).slice(0, 8)}${knownScanIds.length > 1 ? ` … ${clean(knownScanIds[knownScanIds.length - 1]).slice(0, 8)}` : ""}` : ""; const knownSince = - command === "show" && matches?.length && entry["knownSince"] + (command === "show" || command === "finding") && + matches?.length && + entry["knownSince"] ? ` ${accent("·")} ${strong(`Known since ${KNOWN_SINCE_DATE.format(new Date(clean(entry["knownSince"])))}`)}${knownScans}` : ""; const location = (entry["locations"] as JsonObject[] | undefined)?.[0]; const path = entry["path"] ?? + entry["locationPath"] ?? `${location?.["path"]}${location?.["startLine"] ? `:${location["startLine"]}` : ""}`; lines.push(` ${dim(clean(path))}${grouped}${knownSince}`); - const showLinkedFindings = command !== "show" || options.showLinkedFindings; + if (command === "findings" || command === "finding") { + const occurrenceId = entry["occurrenceId"]; + const triage = entry["triage"] as JsonObject | undefined; + const status = triage?.["status"] ?? entry["status"]; + const scanId = entry["scanId"]; + const occurrenceCount = entry["occurrenceCount"]; + const details = [ + ...(occurrenceId ? [`${strong("ID")} ${clean(occurrenceId)}`] : []), + ...(status ? [strong(clean(status).toUpperCase())] : []), + ...(command === "findings" && scanId + ? [`${strong("SCAN")} ${clean(scanId).slice(0, 8)}`] + : []), + ...(command === "findings" && + options.repository === undefined && + typeof entry["targetPath"] === "string" + ? [`${strong("REPOSITORY")} ${clean(entry["targetPath"])}`] + : []), + ...(typeof occurrenceCount === "number" && occurrenceCount > 1 + ? [`${clean(occurrenceCount)} scans`] + : []), + ]; + if (details.length > 0) { + lines.push(` ${details.join(` ${accent("·")} `)}`); + } + } + const showLinkedFindings = + command === "compare" || + command === "finding" || + (command === "show" && options.showLinkedFindings); if (matches?.length && showLinkedFindings) { lines.push(` ${accent("↔")} ${strong("LINKED FINDINGS")}`); for (const match of matches) { @@ -145,7 +185,330 @@ export function renderScanHistory( } }; - if (command === "list") { + if (command === "findings") { + const findings = result["findings"] as JsonObject[]; + const scanId = result["scanId"]; + const repository = options.repository ?? result["repository"]; + const scope = + typeof scanId === "string" + ? `scan ${clean(scanId).slice(0, 8)}` + : typeof repository === "string" + ? clean(basename(repository)) + : "all repositories"; + const offset = Number(result["offset"] ?? 0); + const total = result["total"]; + const range = + typeof total === "number" + ? findings.length > 0 + ? `${offset + 1}-${offset + findings.length} of ${total}` + : `0 of ${total}` + : `${findings.length} finding${findings.length === 1 ? "" : "s"}`; + lines.push(` ${strong(scope)} ${accent("·")} ${range}`); + if (findings.length === 0) { + lines.push("", " No saved findings match these filters."); + } + for (const entry of findings) { + lines.push(""); + finding(entry, false); + } + if (typeof result["nextOffset"] === "number") { + lines.push( + "", + ` ${strong("NEXT PAGE")} rerun with --offset ${clean(result["nextOffset"])}`, + ); + } + if (findings.length > 0) { + lines.push( + "", + ` ${strong("DETAILS")} codex-security findings show OCCURRENCE_ID`, + ); + } + } else if (command === "finding") { + const repository = result["currentTargetPath"] ?? result["targetPath"]; + const scanId = result["scanId"]; + if (typeof repository === "string") { + lines.push( + ` ${strong(clean(basename(repository)))}${scanId ? ` ${accent("·")} ${clean(scanId).slice(0, 8)}` : ""}`, + ); + } + lines.push(""); + finding(result); + if (typeof result["summary"] === "string" && result["summary"]) { + lines.push("", ` ${strong("SUMMARY")}`); + wrap(result["summary"], 4); + } + const locations = result["locations"]; + if (Array.isArray(locations) && locations.length > 1) { + lines.push("", ` ${strong("AFFECTED LOCATIONS")}`); + for (const location of locations) { + if ( + typeof location !== "object" || + location === null || + Array.isArray(location) + ) { + continue; + } + const path = location["path"]; + if (typeof path !== "string") continue; + const startLine = location["startLine"]; + const endLine = location["endLine"]; + const range = + typeof startLine === "number" + ? `:${startLine}${typeof endLine === "number" && endLine !== startLine ? `-${endLine}` : ""}` + : ""; + const role = + typeof location["role"] === "string" + ? ` ${accent("·")} ${clean(location["role"])}` + : ""; + lines.push(` ${dim(clean(`${path}${range}`))}${role}`); + } + } + const description = (value: unknown): string | undefined => { + if (typeof value === "string") return value; + if (typeof value !== "object" || value === null) return undefined; + for (const key of [ + "summary", + "narrative", + "description", + "detail", + "conclusion", + "rationale", + "explanation", + "why", + ]) { + const candidate = (value as JsonObject)[key]; + if (typeof candidate === "string" && candidate) return candidate; + } + return undefined; + }; + const appendDescriptions = ( + sections: string[], + value: JsonObject, + key: string, + label: string, + ): void => { + const items = value[key]; + if (!Array.isArray(items)) return; + for (const item of items) { + const detail = description(item); + if (detail !== undefined) sections.push(`${label}: ${detail}`); + } + }; + for (const [label, key] of [ + ["SEVERITY", "severity"], + ["CONFIDENCE", "confidence"], + ] as const) { + const value = result[key]; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + continue; + } + const level = value["level"]; + const rationale = description(value); + if ( + (key === "severity" && rationale === undefined) || + (typeof level !== "string" && rationale === undefined) + ) { + continue; + } + lines.push( + "", + ` ${strong(label)}${typeof level === "string" ? ` ${strong(clean(level).toUpperCase())}` : ""}`, + ); + if (rationale !== undefined) wrap(rationale, 4); + } + for (const [label, key] of [ + ["ROOT CAUSE", "rootCause"], + ["VALIDATION", "validation"], + ["ATTACK PATH", "attackPath"], + ] as const) { + const value = + key === "rootCause" ? result[key] ?? result["root_cause"] : result[key]; + const detail = description(value); + const sections = detail === undefined ? [] : [detail]; + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + if (key === "validation") { + if (typeof value["method"] === "string") { + sections.push(`Method: ${value["method"]}`); + } + for (const [evidenceLabel, evidenceKey] of [ + ["Verified", "assertions"], + ["Evidence", "evidence"], + ["Counterevidence", "counterEvidence"], + ] as const) { + appendDescriptions(sections, value, evidenceKey, evidenceLabel); + } + } + if (key === "attackPath") { + for (const [nestedLabel, nestedKey] of [ + ["Dataflow", "dataflow"], + ["Dataflow", "dataFlow"], + ["Reachability", "reachability"], + ["Impact", "impact"], + ["Likelihood", "likelihood"], + ] as const) { + const nestedValue = value[nestedKey]; + const nested = description(nestedValue); + if (nested !== undefined) { + sections.push(`${nestedLabel}: ${nested}`); + } + if ( + typeof nestedValue !== "object" || + nestedValue === null || + Array.isArray(nestedValue) + ) { + continue; + } + const attributes: ReadonlyArray = + nestedKey === "dataflow" || nestedKey === "dataFlow" + ? [ + ["Source", "source"], + ["Sink", "sink"], + ["Outcome", "outcome"], + ] + : nestedKey === "reachability" + ? [ + ["Attacker", "attacker"], + ["Entry point", "entrypoint"], + ["Outcome", "outcome"], + ] + : []; + for (const [attributeLabel, attributeKey] of attributes) { + const detail = description(nestedValue[attributeKey]); + if (detail !== undefined) { + sections.push(`${attributeLabel}: ${detail}`); + } + } + if (nestedKey === "reachability") { + appendDescriptions( + sections, + nestedValue, + "preconditions", + "Precondition", + ); + } + } + } + const caveats: ReadonlyArray = + key === "validation" + ? [["Limitation", "limitations"]] + : key === "attackPath" + ? [ + ["Precondition", "preconditions"], + ["Limitation", "limitations"], + ] + : []; + for (const [caveatLabel, caveatKey] of caveats) { + appendDescriptions(sections, value, caveatKey, caveatLabel); + } + } + if (sections.length > 0) { + lines.push("", ` ${strong(label)}`); + for (const section of sections) wrap(section, 4); + } + } + const codeEvidence = result["codeEvidence"] ?? result["code_evidence"]; + const rootCause = result["rootCause"] ?? result["root_cause"]; + const legacyRootCode = + typeof rootCause === "object" && + rootCause !== null && + !Array.isArray(rootCause) && + typeof rootCause["code"] === "string" + ? rootCause["code"] + : undefined; + const evidenceEntries: JsonObject[] = + Array.isArray(codeEvidence) && codeEvidence.length > 0 + ? codeEvidence.filter( + (entry): entry is JsonObject => + typeof entry === "object" && + entry !== null && + !Array.isArray(entry), + ) + : legacyRootCode === undefined + ? [] + : [{ label: "Root-cause source", code: legacyRootCode }]; + if (evidenceEntries.length > 0) { + lines.push("", ` ${strong("CODE EVIDENCE")}`); + for (const evidence of evidenceEntries) { + if (typeof evidence["label"] === "string") { + lines.push(` ${strong(clean(evidence["label"]))}`); + } + if (typeof evidence["path"] === "string") { + const line = + typeof evidence["startLine"] === "number" + ? `:${evidence["startLine"]}` + : ""; + lines.push(` ${dim(clean(`${evidence["path"]}${line}`))}`); + } + if (typeof evidence["explanation"] === "string") { + wrap(evidence["explanation"], 6); + } + if (typeof evidence["code"] === "string") { + for (const sourceLine of evidence["code"].split("\n")) { + lines.push(` ${dim(clean(sourceLine))}`); + } + } + } + } + const sourceExcerpt = result["sourceExcerpt"]; + if (typeof sourceExcerpt === "string" && sourceExcerpt) { + lines.push("", ` ${strong("SOURCE EXCERPT")}`); + for (const sourceLine of sourceExcerpt.split("\n")) { + lines.push(` ${dim(clean(sourceLine))}`); + } + } + if (typeof result["remediation"] === "string" && result["remediation"]) { + lines.push("", ` ${strong("REMEDIATION")}`); + wrap(result["remediation"], 4); + } + for (const [label, key] of [ + ["REMEDIATION TESTS", "remediationTests"], + ["PREVENTIVE CONTROLS", "preventiveControls"], + ] as const) { + const items = result[key]; + if (!Array.isArray(items)) continue; + const guidance = items.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + if (guidance.length === 0) continue; + lines.push("", ` ${strong(label)}`); + for (const item of guidance) wrap(item, 6, " • "); + } + const artifactPaths = result["artifactPaths"]; + if (Array.isArray(artifactPaths) && artifactPaths.length > 0) { + lines.push("", ` ${strong("EVIDENCE ARTIFACTS")}`); + const scanDirectory = result["scanDir"]; + for (const path of artifactPaths) { + if (typeof path !== "string") continue; + const artifactPath = + typeof scanDirectory === "string" ? join(scanDirectory, path) : path; + lines.push(` ${dim(clean(artifactPath))}`); + } + } + if (typeof result["occurrenceId"] === "string") { + const triage = result["triage"]; + const details = + typeof triage === "object" && triage !== null && !Array.isArray(triage) + ? triage + : undefined; + const action = + details?.["status"] === "closed" + ? "" + : ` codex-security findings false-positive ${clean(result["occurrenceId"])} --reason TEXT`; + lines.push("", ` ${strong("TRIAGE")}${action}`); + for (const [label, key] of [ + ["Reason", "closeReason"], + ["Note", "note"], + ] as const) { + if (typeof details?.[key] === "string" && details[key]) { + wrap(`${label}: ${details[key]}`, 4); + } + } + } + } else if (command === "list") { const scans = (result["scans"] as JsonObject[]).filter((scan) => { if ((scan["progress"] as JsonObject)["status"] !== "running") { return true; @@ -164,9 +527,20 @@ export function renderScanHistory( "", ), ); - const latest = scans.find( - (scan) => (scan["progress"] as JsonObject)["status"] === "complete", - )?.["findingCount"]; + const completed = scans + .filter( + (scan) => (scan["progress"] as JsonObject)["status"] === "complete", + ) + .sort((left, right) => { + const leftCompleted = String( + left["completedAt"] ?? left["startedAt"] ?? "", + ); + const rightCompleted = String( + right["completedAt"] ?? right["startedAt"] ?? "", + ); + return rightCompleted.localeCompare(leftCompleted); + }); + const latest = completed[0]?.["findingCount"]; const multipleRepositories = options.repository === undefined && new Set(scans.map((scan) => scan["targetPath"])).size > 1; @@ -202,6 +576,19 @@ export function renderScanHistory( ); } } + if (completed.length > 0) { + const latest = completed[0]!; + const scanPrefix = clean(latest["scanId"]).slice(0, 8); + const scanQualifiedFindings = + options.scanRoot !== undefined || + (options.currentDirectory !== undefined && + latest["targetPath"] !== options.currentDirectory); + lines.push( + "", + ` ${strong("VIEW LATEST")} codex-security scans show ${scanPrefix}`, + ` ${strong("FINDINGS")} codex-security findings list${scanQualifiedFindings ? ` --scan ${scanPrefix}` : ""}`, + ); + } } else if (command === "show") { const status = clean((result["progress"] as JsonObject)["status"]); const statusColor = @@ -303,18 +690,33 @@ export function renderScanHistory( typeof result["findingCount"] === "number" ? result["findingCount"] : findings.length; + const truncated = + Boolean(result["findingsTruncated"]) || count > findings.length; lines.push( "", ` ${strong("FINDINGS")} ${strong( - result["findingsTruncated"] || count > findings.length - ? `${findings.length} of ${count}` - : String(count), + truncated ? `${findings.length} of ${count}` : String(count), )}`, ); for (const entry of findings) { lines.push(""); finding(entry); } + if (truncated) { + lines.push( + "", + ` ${strong("MORE FINDINGS")} codex-security findings list --scan ${clean(result["scanId"]).slice(0, 8)} --offset ${findings.length}`, + ); + } + const linked = findings.some( + (entry) => (entry["matches"] as JsonObject[] | undefined)?.length, + ); + if (linked && !options.showLinkedFindings) { + lines.push( + "", + ` ${strong("FINDING HISTORY")} codex-security scans show ${clean(result["scanId"]).slice(0, 8)} --show-linked-findings`, + ); + } } } else if (command === "compare") { if (result["repository"]) { diff --git a/sdk/typescript/tests-ts/cli-findings.test.ts b/sdk/typescript/tests-ts/cli-findings.test.ts new file mode 100644 index 00000000..f9ed6c3c --- /dev/null +++ b/sdk/typescript/tests-ts/cli-findings.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, test } from "bun:test"; +import type { JsonObject } from "../src/index.js"; +import { main } from "../src/cli.js"; +import { capture, dependencies } from "./support/cli.js"; + +describe("CLI findings history", () => { + test("lists active findings for the current repository by default", async () => { + for (const command of [["findings"], ["findings", "list"]]) { + const calls: Array = []; + const stdout = capture(); + const deps = dependencies({ + onWorkbench: (args): JsonObject => { + calls.push(args); + if (args[0] === "list-scans") { + return { + scans: [ + { + scanId: "scan-1", + targetId: "target-1", + targetPath: "/current/repository", + }, + ], + }; + } + return { + findings: [ + { occurrenceId: "occ-1", title: "Missing authorization" }, + ], + limit: 20, + nextOffset: null, + offset: 0, + }; + }, + }); + deps.createSecurity = () => { + throw new Error("saved findings must not initialize Codex"); + }; + + expect( + await main( + [...command, "--json"], + stdout.stream, + capture().stream, + deps, + ), + ).toBe(0); + expect(calls).toEqual([ + [ + "list-global-findings", + "--repository", + "/current/repository", + "--status", + "open", + "--offset", + "0", + "--limit", + "20", + ], + ]); + expect(JSON.parse(stdout.text())).toMatchObject({ + findings: [{ occurrenceId: "occ-1" }], + }); + } + }); + + test("defaults all-repository finding lists to open without overriding explicit status", async () => { + for (const [arguments_, expectedStatus] of [ + [[], "open"], + [["--status", "closed"], "closed"], + ] as const) { + const calls: Array = []; + expect( + await main( + ["findings", "list", "--all-repositories", ...arguments_], + capture().stream, + capture().stream, + dependencies({ + onWorkbench: (args) => { + calls.push(args); + return { findings: [], limit: 20, nextOffset: null, offset: 0 }; + }, + }), + ), + ).toBe(0); + expect(calls[0]).toContain("--status"); + expect(calls[0]).toContain(expectedStatus); + } + }); + + test("preserves the current checkout when opening a relocated finding", async () => { + const stdout = capture(); + const deps = dependencies({ + onWorkbench: (): JsonObject => ({ + scan: { + scanId: "historical-scan", + targetPath: "/previous/checkout", + currentTargetPath: "/current/repository", + findings: [{ occurrenceId: "historical-occurrence" }], + }, + }), + }); + + expect( + await main( + ["findings", "show", "historical-occurrence", "--json"], + stdout.stream, + capture().stream, + deps, + ), + ).toBe(0); + expect(JSON.parse(stdout.text())).toMatchObject({ + targetPath: "/previous/checkout", + currentTargetPath: "/current/repository", + }); + }); + + test("lists findings across repositories without a target filter", async () => { + const calls: Array = []; + expect( + await main( + [ + "findings", + "list", + "--all-repositories", + "--severity", + "critical", + "--status", + "open", + "--limit", + "5", + ], + capture().stream, + capture().stream, + dependencies({ + onWorkbench: (args) => { + calls.push(args); + return { findings: [], limit: 5, nextOffset: null, offset: 0 }; + }, + }), + ), + ).toBe(0); + expect(calls).toEqual([ + [ + "list-global-findings", + "--severity", + "critical", + "--status", + "open", + "--offset", + "0", + "--limit", + "5", + ], + ]); + }); + + test("paginates and filters findings from a selected historical scan", async () => { + const calls: Array = []; + const stdout = capture(); + expect( + await main( + [ + "findings", + "list", + "--scan", + "31107fbe", + "--query", + "login injection", + "--severity", + "high", + "--status", + "open", + "--offset", + "20", + "--limit", + "5", + "--json", + ], + stdout.stream, + capture().stream, + dependencies({ + onWorkbench: (args) => { + calls.push(args); + return { + findingsPage: { + findings: [{ occurrenceId: "occ-25", title: "Historic SQLi" }], + limit: 5, + nextOffset: null, + offset: 20, + scanId: "31107fbe-full", + total: 21, + }, + }; + }, + }), + ), + ).toBe(0); + expect(calls).toEqual([ + [ + "list-findings", + "--scan-id", + "31107fbe", + "--query", + "login injection", + "--severity", + "high", + "--status", + "open", + "--offset", + "20", + "--limit", + "5", + ], + ]); + expect(JSON.parse(stdout.text())).toEqual({ + findings: [{ occurrenceId: "occ-25", title: "Historic SQLi" }], + limit: 5, + nextOffset: null, + offset: 20, + scanId: "31107fbe-full", + total: 21, + }); + }); + + test("shows a historical occurrence without exposing unrelated findings", async () => { + const calls: Array = []; + const stdout = capture(); + const selected: JsonObject = { + occurrenceId: "occ-25", + severity: { level: "high" }, + title: "Historic SQL injection", + matches: [{ scanId: "previous-scan", title: "Previous injection" }], + remediationTests: ["Reject interpolated account identifiers."], + preventiveControls: ["Require parameterized query helpers."], + }; + expect( + await main( + ["findings", "show", "occ-25", "--json"], + stdout.stream, + capture().stream, + dependencies({ + onWorkbench: (args) => { + calls.push(args); + return { + scan: { + scanId: "31107fbe-full", + scanDir: "/private/results/31107fbe-full", + targetPath: "/current/repository", + findings: [ + { occurrenceId: "occ-other", title: "Unrelated finding" }, + selected, + ], + }, + }; + }, + }), + ), + ).toBe(0); + expect(calls).toEqual([["get-finding", "--occurrence-id", "occ-25"]]); + expect(JSON.parse(stdout.text())).toEqual({ + ...selected, + scanDir: "/private/results/31107fbe-full", + scanId: "31107fbe-full", + targetPath: "/current/repository", + }); + expect(stdout.text()).not.toContain("Unrelated finding"); + }); + + test("shows the latest completed scan without requiring its identifier", async () => { + for (const command of [ + ["scans", "show"], + ["scans", "show", "latest"], + ]) { + const calls: Array = []; + const stdout = capture(); + expect( + await main( + [...command, "--json"], + stdout.stream, + capture().stream, + dependencies({ + onWorkbench: (args): JsonObject => { + calls.push(args); + return args[0] === "list-scans" + ? { + scans: [ + { + scanId: "running", + targetPath: "/current/repository", + progress: { status: "running" }, + }, + { + scanId: "latest", + targetPath: "/current/repository", + progress: { status: "complete" }, + }, + { + scanId: "older", + targetPath: "/current/repository", + progress: { status: "complete" }, + }, + ], + } + : { scan: { scanId: "latest", findings: [] } }; + }, + }), + ), + ).toBe(0); + expect(calls).toEqual([ + ["list-scans", "--repository", "/current/repository"], + ["get-scan", "--scan-id", "latest"], + ]); + expect(JSON.parse(stdout.text())).toEqual({ + scanId: "latest", + findings: [], + }); + } + }); + + test("explains when no completed scan is available", async () => { + const stderr = capture(); + expect( + await main( + ["scans", "show"], + capture().stream, + stderr.stream, + dependencies({ + onWorkbench: () => ({ + scans: [{ scanId: "running", progress: { status: "running" } }], + }), + }), + ), + ).toBe(2); + expect(stderr.text()).toContain("No completed scans found"); + expect(stderr.text()).toContain("codex-security scan ."); + }); + + test("rejects invalid filters before querying saved findings", async () => { + const invalid = [ + ["findings", "list", "--scan", "scan-1", "--all-repositories"], + ["findings", "list", "--limit", "0"], + ["findings", "list", "--limit", "21"], + ["findings", "list", "--offset", "-1"], + ["findings", "list", "--severity", "urgent"], + ["findings", "list", "--scan"], + ["findings", "list", "31107fbe"], + ]; + for (const command of invalid) { + let called = false; + expect( + await main( + command, + capture().stream, + capture().stream, + dependencies({ + onWorkbench: () => { + called = true; + return {}; + }, + }), + ), + ).toBe(2); + expect(called).toBe(false); + } + }); +}); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index b76522e5..200e882c 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -197,8 +197,12 @@ describe("CLI", () => { expect(manifest.text()).toContain( "codex-security findings false-positive ", ); + expect(manifest.text()).toContain("codex-security findings list"); + expect(manifest.text()).toContain( + "codex-security findings show ", + ); expect(manifest.text()).toContain("codex-security scans list [repository]"); - expect(manifest.text()).toContain("codex-security scans show "); + expect(manifest.text()).toContain("codex-security scans show [scanId]"); expect(manifest.text()).toContain("codex-security scans rerun "); expect(manifest.text()).toContain( "codex-security scans match [beforeId] [afterId]", @@ -236,6 +240,8 @@ describe("CLI", () => { ["scans", "rerun"], ["scans", "match"], ["scans", "compare"], + ["findings", "list"], + ["findings", "show"], ["findings", "false-positive"], ] as const; diff --git a/sdk/typescript/tests-ts/scan-history-renderer.test.ts b/sdk/typescript/tests-ts/scan-history-renderer.test.ts index fe0a3e3b..9d7cda73 100644 --- a/sdk/typescript/tests-ts/scan-history-renderer.test.ts +++ b/sdk/typescript/tests-ts/scan-history-renderer.test.ts @@ -297,4 +297,70 @@ describe("scan history renderer", () => { expect(output).toContain(expected); } }); + + test("renders saved findings with identifiers and pagination", () => { + const output = stripVTControlCharacters( + renderScanHistory( + { + scanId: "31107fbe-abcd-4567-abcd-1234567890ab", + findings: [ + { + occurrenceId: "saved-occurrence", + severity: { level: "high" }, + title: "Missing authorization", + locations: [{ path: "routes/login.ts", startLine: 34 }], + triage: { status: "open" }, + }, + ], + offset: 20, + nextOffset: 21, + total: 25, + }, + "findings", + ), + ); + + for (const expected of [ + "SAVED FINDINGS", + "21-21 of 25", + "routes/login.ts:34", + "ID saved-occurrence", + "--offset 21", + "findings show OCCURRENCE_ID", + ]) { + expect(output).toContain(expected); + } + }); + + test("renders stored finding details and authoritative triage", () => { + const output = stripVTControlCharacters( + renderScanHistory( + { + occurrenceId: "saved-occurrence", + scanId: "31107fbe-abcd-4567-abcd-1234567890ab", + targetPath: "/demo/juice-shop", + severity: { level: "high" }, + title: "Missing authorization", + summary: "Customer records are accessible without a session.", + locations: [{ path: "routes/login.ts", startLine: 34 }], + remediation: "Require an authenticated session.", + status: "closed", + triage: { status: "open" }, + }, + "finding", + ), + ); + + for (const expected of [ + "FINDING DETAILS", + "juice-shop", + "OPEN", + "Customer records are accessible without a session.", + "Require an authenticated session.", + "findings false-positive saved-occurrence", + ]) { + expect(output).toContain(expected); + } + expect(output).not.toContain("CLOSED"); + }); }); diff --git a/sdk/typescript/tests-ts/workbench-findings-index.test.ts b/sdk/typescript/tests-ts/workbench-findings-index.test.ts new file mode 100644 index 00000000..65f12ad0 --- /dev/null +++ b/sdk/typescript/tests-ts/workbench-findings-index.test.ts @@ -0,0 +1,375 @@ +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +const findingsIndexProbe = [ + "import argparse, json, os, sqlite3, sys", + "sys.path.insert(0, sys.argv[1])", + "import workbench_native_indexes as indexes", + "from filesystem_identity import serialize_filesystem_identity", + "settings = json.loads(sys.argv[2])", + "connection = sqlite3.connect(':memory:')", + "connection.row_factory = sqlite3.Row", + "connection.executescript('''", + "CREATE TABLE security_targets (id TEXT PRIMARY KEY, current_path TEXT NOT NULL, display_name TEXT NOT NULL);", + "CREATE TABLE scans (id TEXT PRIMARY KEY, target_id TEXT, target_path TEXT, status TEXT, seal_manifest_digest TEXT, started_at TEXT, updated_at TEXT, scope TEXT, scan_dir TEXT);", + "CREATE TABLE finding_occurrences (id TEXT PRIMARY KEY, finding_id TEXT, scan_id TEXT, severity TEXT, created_at TEXT, title TEXT, summary TEXT);", + "CREATE TABLE finding_triage (occurrence_id TEXT, status TEXT, updated_at TEXT);", + "CREATE TABLE finding_locations (occurrence_id TEXT, relative_path TEXT, role TEXT, sort_order INTEGER);", + "''')", + "connection.executemany('INSERT INTO security_targets VALUES (?, ?, ?)', [('current-target', '/current/repository', 'current'), ('stale-target', '/stale/repository', 'stale')])", + "stale_directory = sys.argv[1] if settings.get('coverageFailure') in ('noncanonical', 'pruned') else '/private/tmp/codex-security-findings-index-missing-stale'", + "connection.executemany('INSERT INTO scans VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [", + " ('current-old', 'current-target', '/current/repository', 'complete', 'sealed', '2026-01-01', '2026-01-01', '.', '/private/tmp/current-old'),", + " ('current-new', 'current-target', '/current/repository', 'complete', 'sealed', '2026-02-01', '2026-02-01', '.', '/private/tmp/current-new'),", + " ('reused-legacy', None, '/current/repository', 'complete', 'sealed', '2026-03-01', '2026-03-01', '.', '/private/tmp/reused-legacy'),", + " ('stale-old', 'stale-target', '/stale/repository', 'complete', 'sealed', '2026-01-01', '2026-01-01', '.', '/private/tmp/stale-old'),", + " ('stale-new', 'stale-target', '/stale/repository', 'complete', 'sealed', '2026-02-01', '2026-02-01', '.', stale_directory),", + " ('orphan-old', None, '/orphan/repository', 'complete', 'sealed', '2026-01-01', '2026-01-01', '.', '/private/tmp/orphan-old'),", + " ('orphan-new', None, '/orphan/repository', 'complete', 'sealed', '2026-02-01', '2026-02-01', '.', '/private/tmp/orphan-new'),", + "])", + "if settings.get('mixedLegacyOwnership'):", + " connection.execute('ALTER TABLE scans ADD COLUMN target_device INTEGER')", + " connection.execute('ALTER TABLE scans ADD COLUMN target_inode INTEGER')", + " connection.execute(\"UPDATE scans SET target_device = 7, target_inode = 9 WHERE id = 'current-new'\")", + "if settings.get('replacedCheckout'):", + " connection.execute('ALTER TABLE scans ADD COLUMN target_device INTEGER')", + " connection.execute('ALTER TABLE scans ADD COLUMN target_inode INTEGER')", + " connection.execute('ALTER TABLE scans ADD COLUMN target_revision TEXT')", + " connection.execute(\"UPDATE scans SET target_device = -1, target_inode = -1 WHERE target_id = 'current-target'\")", + " connection.execute(\"UPDATE security_targets SET current_path = ? WHERE id = 'current-target'\", (sys.argv[1],))", + "if settings.get('ownershipTransition') or settings.get('ownershipReuse'):", + " connection.execute('ALTER TABLE scans ADD COLUMN target_device INTEGER')", + " connection.execute('ALTER TABLE scans ADD COLUMN target_inode INTEGER')", + " connection.execute('ALTER TABLE scans ADD COLUMN target_revision TEXT')", + " connection.execute(\"UPDATE security_targets SET current_path = ? WHERE id = 'current-target'\", (sys.argv[1],))", + " connection.execute(\"UPDATE scans SET target_path = ? WHERE target_id = 'current-target'\", (sys.argv[1],))", + " metadata = os.stat(sys.argv[1])", + " if settings.get('ownershipReuse'):", + " connection.execute(\"UPDATE scans SET target_device = ?, target_inode = ?, started_at = '2027-01-01' WHERE id = 'current-old'\", (serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino)))", + " connection.execute(\"INSERT INTO scans (id, target_id, target_path, status, seal_manifest_digest, started_at, updated_at, scope, scan_dir, target_device, target_inode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\", ('previous-owner-identity', 'current-target', sys.argv[1], 'complete', 'sealed', '2026-01-15', '2026-01-15', '.', '/private/tmp/previous-owner', -1, -1))", + " connection.execute(\"DELETE FROM scans WHERE id = 'current-new'\")", + " connection.execute(\"INSERT INTO scans (id, target_id, target_path, status, seal_manifest_digest, started_at, updated_at, scope, scan_dir, target_device, target_inode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\", ('current-new', 'current-target', sys.argv[1], 'complete', 'sealed', '2026-02-01', '2026-02-01', '.', '/private/tmp/current-new', serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino)))", + "connection.executemany('INSERT INTO finding_occurrences VALUES (?, ?, ?, ?, ?, ?, ?)', [", + " ('current-old-occurrence', 'current-old-finding', 'current-old', 'high', '2026-01-01', 'Resolved current finding', 'Older issue'),", + " ('current-new-occurrence', 'current-new-finding', 'current-new', 'critical', '2026-02-01', 'Current CLI finding', 'Latest issue'),", + " ('reused-legacy-occurrence', 'previous-owner-finding', 'reused-legacy', 'critical', '2026-03-01', 'Previous owner secret', 'Must never cross checkout owners'),", + " ('stale-old-occurrence', 'stale-finding', 'stale-old', 'medium', '2026-01-01', 'Unavailable follow-up', 'Coverage is unavailable'),", + " ('orphan-old-occurrence', 'orphan-old-finding', 'orphan-old', 'high', '2026-01-01', 'Older orphan finding', 'Still outside follow-up coverage'),", + " ('orphan-new-occurrence', 'orphan-new-finding', 'orphan-new', 'medium', '2026-02-01', 'Latest orphan finding', 'Target row does not exist'),", + "])", + "if settings.get('lateCompletion'):", + " connection.execute(\"UPDATE finding_occurrences SET finding_id = 'current-new-finding', created_at = '2026-03-01' WHERE id = 'current-old-occurrence'\")", + "connection.executemany('INSERT INTO finding_locations VALUES (?, ?, ?, ?)', [", + " ('current-old-occurrence', 'src/old.py', 'root_control', 0),", + " ('current-new-occurrence', 'src/new.py', 'root_control', 0),", + " ('current-new-occurrence', 'src/secondary.py', 'sink', 1),", + " ('current-new-occurrence', 'src/ÄUTH-Straße.py', 'sink', 2),", + " ('reused-legacy-occurrence', 'src/previous-owner.py', 'root_control', 0),", + " ('stale-old-occurrence', 'src/stale.py', 'root_control', 0),", + " ('orphan-old-occurrence', 'src/orphan-old.py', 'root_control', 0),", + " ('orphan-new-occurrence', 'src/orphan-new.py', 'root_control', 0),", + "])", + "coverage_reads = []", + "def coverage(scan):", + " coverage_reads.append(scan['id'])", + " if settings.get('mixedLegacyOwnership') and scan['id'] == 'current-new':", + " return {'completeness': 'partial', 'includePaths': ['src/new.py'], 'excludePaths': [], 'explicitExclusions': []}", + " if scan['id'] == 'stale-new':", + " if settings.get('coverageFailure') == 'tampered':", + " raise SystemExit('The sealed scan manifest changed after completion.')", + " if settings.get('coverageFailure') == 'sealedArtifact':", + " raise SystemExit('coverage.json: sealed artifact changed or is missing')", + " if settings.get('coverageFailure') == 'pruned':", + " raise SystemExit('coverage.json: expected a regular file inside the scan directory.')", + " raise SystemExit('Scan directory must be an existing canonical non-symlink directory.')", + " if scan['id'] == 'orphan-new':", + " return {'completeness': 'partial', 'includePaths': ['src/orphan-new.py'], 'excludePaths': [], 'explicitExclusions': []}", + " return {'completeness': 'complete', 'includePaths': ['.'], 'excludePaths': [], 'explicitExclusions': []}", + "location_queries = []", + "connection.set_trace_callback(lambda statement: location_queries.append(statement) if 'finding_locations' in statement else None)", + "args = argparse.Namespace(query=settings.get('query'), severity=None, status=None, target_id=settings.get('targetIds') or settings.get('targetId'), target_path=settings.get('targetPaths') or settings.get('targetPath'), offset=0, limit=20)", + "if settings.get('repositories'):", + " indexes.scan_history.list_scans = lambda connection: {'scans': [{'scanId': row['id'], 'targetId': row['target_id']} for row in connection.execute('SELECT id, target_id FROM scans')]}", + " result = indexes.list_repositories(connection, read_coverage=coverage)", + "else:", + " result = indexes.list_global_findings(connection, args, read_coverage=coverage)", + "scoped_scan_ids = []", + "matching_scan_count = None", + "old_owner_matches = None", + "if settings.get('ownershipTransition') or settings.get('ownershipReuse'):", + " clauses, values, _, _ = indexes.scan_history.repository_scan_scope(connection, sys.argv[1])", + " scoped_scan_ids = [row['id'] for row in connection.execute('SELECT scans.id FROM scans WHERE ' + ' AND '.join(clauses), values)]", + "if settings.get('ownershipReuse'):", + " connection.execute('CREATE TABLE scan_comparisons (before_scan_id TEXT, after_scan_id TEXT)')", + " matching = indexes.scan_history.list_unmatched_scan_pairs(connection, argparse.Namespace(repository=sys.argv[1], force=False), backfill_finding_details=lambda _connection, _scan: None, read_coverage=coverage)", + " matching_scan_count = matching['scanCount']", + " scans = [connection.execute('SELECT * FROM scans WHERE id = ?', (scan,)).fetchone() for scan in ('current-old', 'current-new')]", + " old_owner_matches = indexes.scan_history._same_registered_repository(connection, *scans)", + "print(json.dumps({'findings': result.get('findings', []), 'repositories': result.get('repositories', []), 'coverageReads': coverage_reads, 'locationQueryCount': len(location_queries), 'scopedScanIds': scoped_scan_ids, 'matchingScanCount': matching_scan_count, 'oldOwnerMatches': old_owner_matches}))", +].join("\n"); + +function runFindingsIndex( + targetId: string | null, + settings: { + targetIds?: string[]; + targetPath?: string; + targetPaths?: string[]; + query?: string; + coverageFailure?: "tampered" | "sealedArtifact" | "noncanonical" | "pruned"; + lateCompletion?: boolean; + mixedLegacyOwnership?: boolean; + ownershipReuse?: boolean; + ownershipTransition?: boolean; + replacedCheckout?: boolean; + repositories?: boolean; + } = {}, +) { + const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) { + throw new Error( + "A Python interpreter is required for findings-index tests.", + ); + } + return Bun.spawnSync( + [ + python, + "-I", + "-B", + "-c", + findingsIndexProbe, + join(PLUGIN_ROOT, "scripts"), + JSON.stringify({ targetId, ...settings }), + ], + { stdout: "pipe", stderr: "pipe" }, + ); +} + +function probeFindingsIndex( + targetId: string | null, + settings: { + targetIds?: string[]; + targetPath?: string; + targetPaths?: string[]; + query?: string; + coverageFailure?: "pruned"; + lateCompletion?: boolean; + mixedLegacyOwnership?: boolean; + ownershipReuse?: boolean; + ownershipTransition?: boolean; + replacedCheckout?: boolean; + repositories?: boolean; + } = {}, +): { + findings: Array<{ + occurrenceId: string; + scanId: string; + targetId: string | null; + targetPath: string; + }>; + repositories: Array<{ targetId: string; openFindingsCount: number }>; + coverageReads: string[]; + locationQueryCount: number; + matchingScanCount: number | null; + oldOwnerMatches: boolean | null; + scopedScanIds: string[]; +} { + const result = runFindingsIndex(targetId, settings); + expect(new TextDecoder().decode(result.stderr)).toBe(""); + expect(result.exitCode).toBe(0); + return JSON.parse(new TextDecoder().decode(result.stdout)); +} + +describe("workbench findings index", () => { + test("isolates targetless previous-owner findings and coverage reads", () => { + const result = probeFindingsIndex("current-target"); + + expect(result.findings).toEqual([ + expect.objectContaining({ + occurrenceId: "current-new-occurrence", + scanId: "current-new", + targetId: "current-target", + }), + ]); + expect(result.coverageReads).toEqual(["current-new"]); + expect(result.findings).not.toContainEqual( + expect.objectContaining({ occurrenceId: "reused-legacy-occurrence" }), + ); + }); + + test("keeps legacy findings after newer scans record filesystem ownership", () => { + const result = probeFindingsIndex("current-target", { + mixedLegacyOwnership: true, + }); + + expect(result.findings).toEqual([ + expect.objectContaining({ occurrenceId: "current-new-occurrence" }), + expect.objectContaining({ occurrenceId: "current-old-occurrence" }), + ]); + expect(result.findings).not.toContainEqual( + expect.objectContaining({ occurrenceId: "reused-legacy-occurrence" }), + ); + }); + + test("counts only active findings when listing repositories", () => { + const result = probeFindingsIndex(null, { repositories: true }); + + expect(result.repositories).toContainEqual( + expect.objectContaining({ + targetId: "current-target", + openFindingsCount: 1, + }), + ); + }); + + test("excludes replaced checkout owners from findings and repository counts", () => { + expect( + probeFindingsIndex("current-target", { replacedCheckout: true }).findings, + ).toEqual([]); + expect( + probeFindingsIndex(null, { replacedCheckout: true }).findings, + ).not.toContainEqual( + expect.objectContaining({ targetId: "current-target" }), + ); + + expect( + probeFindingsIndex(null, { + replacedCheckout: true, + repositories: true, + }).repositories, + ).toContainEqual( + expect.objectContaining({ + targetId: "current-target", + openFindingsCount: 0, + }), + ); + }); + + test("drops ambiguous legacy history after checkout ownership changes", () => { + const result = probeFindingsIndex("current-target", { + ownershipTransition: true, + }); + + expect(result.findings).toEqual([ + expect.objectContaining({ occurrenceId: "current-new-occurrence" }), + ]); + expect(result.scopedScanIds).toContain("current-new"); + expect(result.scopedScanIds).not.toContain("current-old"); + expect( + probeFindingsIndex(null, { + ownershipTransition: true, + repositories: true, + }).repositories, + ).toContainEqual( + expect.objectContaining({ + targetId: "current-target", + openFindingsCount: 1, + }), + ); + }); + + test("rejects recycled filesystem identities after the system clock moves backward", () => { + const result = probeFindingsIndex("current-target", { + ownershipReuse: true, + }); + + expect(result.findings).toEqual([ + expect.objectContaining({ occurrenceId: "current-new-occurrence" }), + ]); + expect(result.scopedScanIds).toContain("current-new"); + expect(result.scopedScanIds).not.toContain("current-old"); + expect(result.matchingScanCount).toBe(1); + expect(result.oldOwnerMatches).toBe(false); + }); + + test("keeps active findings when a later scan artifact was pruned", () => { + const result = probeFindingsIndex("stale-target", { + coverageFailure: "pruned", + }); + + expect(result.findings).toEqual([ + expect.objectContaining({ occurrenceId: "stale-old-occurrence" }), + ]); + expect(result.coverageReads).toEqual(["stale-new"]); + }); + + test.each(["tampered", "sealedArtifact"] as const)( + "rejects %s sealed scan artifacts", + (coverageFailure) => { + const result = runFindingsIndex("stale-target", { coverageFailure }); + + expect(result.exitCode).not.toBe(0); + expect(new TextDecoder().decode(result.stderr)).toContain("changed"); + }, + ); + + test("indexes every targetless scan even without a saved target", () => { + const result = probeFindingsIndex(null, { + targetPath: "/orphan/repository", + }); + + expect(result.findings).toEqual([ + expect.objectContaining({ + occurrenceId: "orphan-old-occurrence", + targetId: null, + targetPath: "/orphan/repository", + }), + expect.objectContaining({ + occurrenceId: "orphan-new-occurrence", + targetId: null, + targetPath: "/orphan/repository", + }), + ]); + expect(result.coverageReads).toEqual(["orphan-new"]); + }); + + test("keeps multi-target repository queries inside the selected checkout", () => { + const scoped = probeFindingsIndex(null, { + targetPaths: ["/current/repository", "/orphan/repository"], + }); + expect(scoped.findings.map((finding) => finding.occurrenceId)).toEqual([ + "current-new-occurrence", + "orphan-old-occurrence", + "orphan-new-occurrence", + ]); + expect(scoped.coverageReads).toEqual(["current-new", "orphan-new"]); + + const siblingPrefix = probeFindingsIndex(null, { + targetPaths: ["/current/repositor"], + }); + expect(siblingPrefix.findings).toEqual([]); + expect(siblingPrefix.coverageReads).toEqual([]); + }); + + test("combines exact target identities with legacy checkout paths", () => { + const identified = probeFindingsIndex(null, { + targetIds: ["current-target", "stale-target"], + }); + expect(identified.findings.map((finding) => finding.occurrenceId)).toEqual([ + "current-new-occurrence", + "stale-old-occurrence", + ]); + + const mixed = probeFindingsIndex(null, { + targetIds: ["current-target"], + targetPaths: ["/orphan/repository"], + }); + expect(mixed.findings.map((finding) => finding.occurrenceId)).toEqual([ + "current-new-occurrence", + "orphan-old-occurrence", + "orphan-new-occurrence", + ]); + }); + + test("searches secondary finding source locations", () => { + for (const query of ["SECONDARY.PY", "äuth-strasse.py"]) { + const result = probeFindingsIndex("current-target", { query }); + + expect(result.findings).toEqual([ + expect.objectContaining({ occurrenceId: "current-new-occurrence" }), + ]); + expect(result.locationQueryCount).toBe(1); + } + }); +}); diff --git a/sdk/typescript/tests-ts/workbench-scan-history.test.ts b/sdk/typescript/tests-ts/workbench-scan-history.test.ts index 65529e87..b205aa13 100644 --- a/sdk/typescript/tests-ts/workbench-scan-history.test.ts +++ b/sdk/typescript/tests-ts/workbench-scan-history.test.ts @@ -1,5 +1,4 @@ import { spawnSync } from "node:child_process"; -import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; @@ -10,40 +9,39 @@ test("loads each scan's matching findings once across historical batches", () => if (python === null) throw new Error("A Python interpreter is required."); const probe = [ - "import argparse, json, sqlite3, sys", + "import argparse, json, os, sqlite3, sys, tempfile", "sys.path.insert(0, sys.argv[1])", "import workbench_scan_history as history", + "from filesystem_identity import serialize_filesystem_identity", + "directory = tempfile.TemporaryDirectory(prefix='codex-security-matching-fixture-')", + "repository = os.path.realpath(directory.name)", "connection = sqlite3.connect(':memory:')", "connection.row_factory = sqlite3.Row", "connection.executescript('''", "CREATE TABLE security_targets (id TEXT, current_path TEXT);", - "CREATE TABLE scans (id TEXT, target_path TEXT, target_id TEXT, status TEXT, started_at TEXT);", + "CREATE TABLE scans (id TEXT, target_path TEXT, target_id TEXT, target_device INTEGER, target_inode INTEGER, target_revision TEXT, status TEXT, started_at TEXT);", "CREATE TABLE scan_comparisons (before_scan_id TEXT, after_scan_id TEXT);", "CREATE TABLE finding_occurrences (id TEXT, finding_id TEXT, scan_id TEXT, details_json TEXT, remediation TEXT, severity TEXT, summary TEXT, title TEXT);", "CREATE TABLE finding_triage (occurrence_id TEXT, status TEXT, close_reason TEXT);", "CREATE TABLE finding_locations (occurrence_id TEXT, relative_path TEXT, role TEXT, sort_order INTEGER);", "''')", + "metadata = os.stat(repository)", + "identity = (serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino))", + "connection.execute('INSERT INTO security_targets VALUES (?, ?)', ('owned-target', repository))", "for index in range(3):", " scan = f'scan-{index}'", - " connection.execute('INSERT INTO scans VALUES (?, ?, NULL, ?, ?)', (scan, sys.argv[2], 'complete', str(index)))", + " connection.execute('INSERT INTO scans VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (scan, repository, 'owned-target', *identity, 'unversioned', 'complete', str(index)))", " connection.execute('INSERT INTO finding_occurrences VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (scan, scan, scan, '{}', 'fix', 'high', 'summary', 'title'))", "queries = []", "connection.set_trace_callback(queries.append)", "backfilled = []", - "result = history.list_unmatched_scan_pairs(connection, argparse.Namespace(repository=sys.argv[2], force=False), backfill_finding_details=lambda _connection, scan: backfilled.append(scan['id']), read_coverage=lambda _scan: {})", + "result = history.list_unmatched_scan_pairs(connection, argparse.Namespace(repository=repository, force=False), backfill_finding_details=lambda _connection, scan: backfilled.append(scan['id']), read_coverage=lambda _scan: {})", "print(json.dumps({'result': result, 'backfilled': backfilled, 'findingQueries': sum('FROM finding_occurrences AS occurrences' in query for query in queries)}))", ].join("\n"); const result = spawnSync( python, - [ - "-I", - "-B", - "-c", - probe, - join(PLUGIN_ROOT, "scripts"), - join(tmpdir(), "codex-security-matching-fixture"), - ], + ["-I", "-B", "-c", probe, join(PLUGIN_ROOT, "scripts")], { encoding: "utf8", timeout: 10_000 }, ); @@ -64,3 +62,94 @@ test("loads each scan's matching findings once across historical batches", () => }, }); }); + +test("compares registered scan history after its checkout moves", () => { + const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + + const probe = [ + "import json, os, pathlib, sqlite3, sys, tempfile", + "sys.path.insert(0, sys.argv[1])", + "import workbench_scan_history as history", + "from filesystem_identity import serialize_filesystem_identity", + "with tempfile.TemporaryDirectory() as temporary:", + " old = pathlib.Path(temporary) / 'old-checkout'", + " old.mkdir()", + " identity = os.stat(old)", + " moved = pathlib.Path(temporary) / 'moved-checkout'", + " old.rename(moved)", + " connection = sqlite3.connect(':memory:')", + " connection.row_factory = sqlite3.Row", + " connection.execute('CREATE TABLE security_targets (id TEXT, current_path TEXT)')", + " connection.execute('INSERT INTO security_targets VALUES (?, ?)', ('owned-target', str(moved)))", + " rows = [connection.execute('SELECT ? AS target_id, ? AS target_path, ? AS target_device, ? AS target_inode', ('owned-target', str(path), serialize_filesystem_identity(identity.st_dev), serialize_filesystem_identity(identity.st_ino))).fetchone() for path in (old, moved)]", + " print(json.dumps(history._same_registered_repository(connection, *rows)))", + ].join("\n"); + + const result = spawnSync( + python, + ["-I", "-B", "-c", probe, join(PLUGIN_ROOT, "scripts")], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toBe(true); +}); + +test("includes linked worktrees and recognizes separately verified clones", () => { + const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + + const probe = [ + "import json, os, pathlib, sqlite3, subprocess, sys, tempfile", + "sys.path.insert(0, sys.argv[1])", + "import workbench_scan_history as history", + "from filesystem_identity import serialize_filesystem_identity", + "with tempfile.TemporaryDirectory() as temporary:", + " root = pathlib.Path(temporary).resolve() / 'repository'", + " linked = pathlib.Path(temporary).resolve() / 'linked-worktree'", + " clone = pathlib.Path(temporary).resolve() / 'repository-clone'", + " unregistered = pathlib.Path(temporary).resolve() / 'unregistered-clone'", + " subprocess.run(['git', 'init', '-q', '-b', 'main', str(root)], check=True)", + " (root / 'source.py').write_text('print(1)\\n')", + " subprocess.run(['git', '-C', str(root), 'add', 'source.py'], check=True)", + " subprocess.run(['git', '-C', str(root), '-c', 'user.name=Inventory Test', '-c', 'user.email=inventory@example.test', 'commit', '-qm', 'initial'], check=True)", + " subprocess.run(['git', '-C', str(root), 'worktree', 'add', '-q', '-b', 'linked', str(linked)], check=True)", + " subprocess.run(['git', '-C', str(root), 'remote', 'add', 'origin', 'https://github.com/example/project.git'], check=True)", + " subprocess.run(['git', 'clone', '-q', str(root), str(clone)], check=True)", + " subprocess.run(['git', '-C', str(clone), 'remote', 'set-url', 'origin', 'git@github.com:example/project.git'], check=True)", + " subprocess.run(['git', 'clone', '-q', str(root), str(unregistered)], check=True)", + " subprocess.run(['git', '-C', str(unregistered), 'remote', 'set-url', 'origin', 'git@github.com:example/project.git'], check=True)", + " nested = root / 'src'", + " nested.mkdir()", + " connection = sqlite3.connect(':memory:')", + " connection.row_factory = sqlite3.Row", + " connection.executescript('CREATE TABLE security_targets (id TEXT, current_path TEXT); CREATE TABLE scans (id TEXT, target_id TEXT, target_path TEXT, target_device INTEGER, target_inode INTEGER, target_revision TEXT, started_at TEXT);')", + " for target_id, target in [('main', root), ('linked', linked), ('clone', clone)]:", + " metadata = target.stat()", + " connection.execute('INSERT INTO security_targets VALUES (?, ?)', (target_id, str(target)))", + " connection.execute('INSERT INTO scans VALUES (?, ?, ?, ?, ?, ?, ?)', (target_id, target_id, str(target), serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino), 'revision', '2026-01-01'))", + " connection.execute('INSERT INTO scans VALUES (?, ?, ?, ?, ?, ?, ?)', ('local-legacy', None, str(nested), None, None, 'revision', '2026-01-02'))", + " _, _, target_ids, _ = history.repository_scan_scope(connection, nested)", + " scans = [connection.execute('SELECT * FROM scans WHERE id = ?', (target_id,)).fetchone() for target_id in ('main', 'clone')]", + " untrusted = connection.execute(\"SELECT '' AS target_id, ? AS target_path\", (str(unregistered),)).fetchone()", + " _, _, unregistered_targets, _ = history.repository_scan_scope(connection, unregistered)", + " print(json.dumps({'targets': sorted(target_ids), 'clone': history._same_repository(*scans, require_ownership=True), 'untrusted': history._same_repository(scans[0], untrusted), 'unregistered': unregistered_targets}))", + ].join("\n"); + + const result = spawnSync( + python, + ["-I", "-B", "-c", probe, join(PLUGIN_ROOT, "scripts")], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + targets: ["linked", "main"], + clone: true, + untrusted: false, + unregistered: [], + }); +});