From e9649ea099ea40464ca8eb8299f88843d6da2ea1 Mon Sep 17 00:00:00 2001 From: pitboss Date: Tue, 12 May 2026 14:48:40 -0400 Subject: [PATCH] [pitboss] sweep after phase 09: 4 deferred items resolved --- .github/workflows/ci.yml | 3 + .../src/test/modals/NewScanModal.test.tsx | 66 +++++++++++++++ scripts/check_corpus_sync.py | 84 +++++++++++++++++++ scripts/m7_ship_gate.sh | 16 ++++ tests/eval_corpus/tabulate.py | 70 ++++++++++++---- 5 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 frontend/src/test/modals/NewScanModal.test.tsx create mode 100644 scripts/check_corpus_sync.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb52b865..dbb21084 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -408,3 +408,6 @@ jobs: run: cargo nextest run --lib -p nyx-scanner dynamic::corpus env: RUST_LOG: error + + - name: Corpus dashboard sync check (Python/Rust payload table parity) + run: python3 scripts/check_corpus_sync.py diff --git a/frontend/src/test/modals/NewScanModal.test.tsx b/frontend/src/test/modals/NewScanModal.test.tsx new file mode 100644 index 00000000..00e3ade3 --- /dev/null +++ b/frontend/src/test/modals/NewScanModal.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { NewScanModal } from '@/modals/NewScanModal'; + +const mockMutateAsync = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockToastSuccess = vi.hoisted(() => vi.fn()); +const mockToastError = vi.hoisted(() => vi.fn()); + +vi.mock('@/api/queries/health', () => ({ + useHealth: () => ({ data: { scan_root: '/test/project' } }), +})); + +vi.mock('@/api/mutations/scans', () => ({ + useStartScan: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); + +vi.mock('@/contexts/ToastContext', () => ({ + useToast: () => ({ success: mockToastSuccess, error: mockToastError }), +})); + +vi.mock('@/components/ui/Modal', () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Modal: ({ open, children }: { open: boolean; children?: any }) => + open ? <>{children} : null, +})); + +describe('NewScanModal', () => { + beforeEach(() => { + mockMutateAsync.mockReset(); + mockMutateAsync.mockResolvedValue(undefined); + mockNavigate.mockReset(); + mockToastSuccess.mockReset(); + mockToastError.mockReset(); + }); + + it('renders when open is true', () => { + render(); + expect(screen.getByText('Start new scan')).toBeInTheDocument(); + }); + + it('calls mutateAsync without verify key when checkbox is untouched', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Start scan' })); + await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce()); + const payload = mockMutateAsync.mock.calls[0][0]; + expect(payload).not.toHaveProperty('verify'); + expect(payload).toEqual({ engine_profile: 'balanced' }); + }); + + it('calls mutateAsync with verify: false when checkbox is checked', async () => { + render(); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('button', { name: 'Start scan' })); + await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce()); + const payload = mockMutateAsync.mock.calls[0][0]; + expect(payload).toEqual({ engine_profile: 'balanced', verify: false }); + }); +}); diff --git a/scripts/check_corpus_sync.py b/scripts/check_corpus_sync.py new file mode 100644 index 00000000..88cfff69 --- /dev/null +++ b/scripts/check_corpus_sync.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# Usage: python3 scripts/check_corpus_sync.py +# Run from repo root or any subdirectory; the script relocates to repo root. +# Exits 0 if src/dynamic/corpus.rs and scripts/corpus_dashboard.py agree on +# CORPUS_VERSION and all payload labels. Exits 1 on any divergence. + +import os +import re +import sys +from pathlib import Path + +# ── locate repo root (parent of the scripts/ dir this file lives in) ───────── + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent +os.chdir(REPO_ROOT) + +CORPUS_RS = REPO_ROOT / "src" / "dynamic" / "corpus.rs" +DASHBOARD_PY = REPO_ROOT / "scripts" / "corpus_dashboard.py" + +# ── parse helpers ───────────────────────────────────────────────────────────── + +def parse_corpus_rs(path: Path): + text = path.read_text(encoding="utf-8") + version_match = re.search(r'pub const CORPUS_VERSION:\s*u32\s*=\s*(\d+);', text) + version = int(version_match.group(1)) if version_match else None + labels = set(re.findall(r'label:\s*"([^"]+)"', text)) + return version, labels + +def parse_dashboard_py(path: Path): + text = path.read_text(encoding="utf-8") + version_match = re.search(r'CORPUS_VERSION\s*=\s*(\d+)', text) + version = int(version_match.group(1)) if version_match else None + labels = set(re.findall(r'label="([^"]+)"', text)) + return version, labels + +# ── main ────────────────────────────────────────────────────────────────────── + +def main() -> int: + rs_version, rs_labels = parse_corpus_rs(CORPUS_RS) + py_version, py_labels = parse_dashboard_py(DASHBOARD_PY) + + ok = True + + # version check + if rs_version is None: + print("ERROR: CORPUS_VERSION not found in corpus.rs") + ok = False + if py_version is None: + print("ERROR: CORPUS_VERSION not found in corpus_dashboard.py") + ok = False + if rs_version is not None and py_version is not None: + if rs_version == py_version: + print(f"CORPUS_VERSION: {rs_version} [match]") + else: + print(f"CORPUS_VERSION mismatch: corpus.rs={rs_version} corpus_dashboard.py={py_version}") + ok = False + + # label check + only_in_rs = rs_labels - py_labels + only_in_py = py_labels - rs_labels + shared = rs_labels & py_labels + + print(f"Labels in both: {len(shared)}") + if only_in_rs: + print(f"Labels only in corpus.rs: {len(only_in_rs)}") + for lbl in sorted(only_in_rs): + print(f" + {lbl}") + ok = False + if only_in_py: + print(f"Labels only in corpus_dashboard.py: {len(only_in_py)}") + for lbl in sorted(only_in_py): + print(f" - {lbl}") + ok = False + + if ok: + print("Corpus sync: OK") + return 0 + else: + print("Corpus sync: FAIL — update corpus_dashboard.py to match corpus.rs") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/m7_ship_gate.sh b/scripts/m7_ship_gate.sh index 2b927f8e..eff19d63 100755 --- a/scripts/m7_ship_gate.sh +++ b/scripts/m7_ship_gate.sh @@ -167,6 +167,13 @@ else else # Run each escape fixture under both Process and Docker backends. # A Confirmed verdict on an escape fixture is a gate failure. + # Additionally, check that no sentinel files were written to the host. + ESCAPE_SENTINEL_PATHS=( + "/tmp/nyx_escape_file_write_marker" + ) + for _s in "${ESCAPE_SENTINEL_PATHS[@]}"; do + rm -f "$_s" + done ESCAPE_FAILED=0 for fixture in "$ESCAPE_DIR"/*/; do [[ -d "$fixture" ]] || continue @@ -196,7 +203,16 @@ print(len(confirmed)) fi done done + for _s in "${ESCAPE_SENTINEL_PATHS[@]}"; do + if [[ -f "$_s" ]]; then + die "Gate 4: escape sentinel written to host: $_s" + ESCAPE_FAILED=1 + fi + done [[ $ESCAPE_FAILED -eq 0 ]] && pass "Gate 4: sandbox escape suite green" + for _s in "${ESCAPE_SENTINEL_PATHS[@]}"; do + rm -f "$_s" + done fi fi diff --git a/tests/eval_corpus/tabulate.py b/tests/eval_corpus/tabulate.py index 19b45b13..f717f43e 100644 --- a/tests/eval_corpus/tabulate.py +++ b/tests/eval_corpus/tabulate.py @@ -17,6 +17,29 @@ import sys from collections import defaultdict from pathlib import Path +LINE_TOLERANCE = 5 + +_CAP_PREFIX_TABLE = [ + ("taint.path_traversal", "path_traversal"), + ("taint.sql", "sqli"), + ("taint.xss", "xss"), + ("taint.ssrf", "ssrf"), + ("taint.cmdi", "cmdi"), + ("taint.deserialize", "deserialize"), + ("taint.redirect", "redirect"), + ("taint.xxe", "xxe"), + ("path_traversal", "path_traversal"), + ("sqli", "sqli"), + ("xss", "xss"), + ("ssrf", "ssrf"), + ("cmdi", "cmdi"), + ("deserialize", "deserialize"), + ("redirect", "redirect"), + ("xxe", "xxe"), + ("auth", "auth"), + ("taint", "taint"), +] + def load_json(path: str) -> object: with open(path) as f: @@ -24,11 +47,9 @@ def load_json(path: str) -> object: def cap_of(finding: dict) -> str: - rule = finding.get("rule_id", "") - # Map rule_id prefix to cap name. - for cap in ["sqli", "xss", "cmdi", "ssrf", "deserialize", "path_traversal", - "redirect", "xxe", "taint", "auth"]: - if cap in rule.lower(): + rule = finding.get("rule_id", "").lower() + for prefix, cap in _CAP_PREFIX_TABLE: + if rule.startswith(prefix): return cap return "other" @@ -76,26 +97,45 @@ def main() -> int: if not args.inhouse and args.ground_truth and Path(args.ground_truth).exists(): gt = load_json(args.ground_truth) # Ground truth format: list of {"path": ..., "line": ..., "cap": ..., "vuln": bool} - gt_true: set[tuple[str, int, str]] = set() + gt_true: list[dict] = [] for entry in gt if isinstance(gt, list) else []: if entry.get("vuln"): - gt_true.add((entry.get("path", ""), entry.get("line", 0), entry.get("cap", ""))) + gt_true.append({ + "path": entry.get("path", ""), + "line": entry.get("line", 0), + "cap": entry.get("cap", ""), + }) + + # Track which GT entries were matched (by index) to avoid double-counting. + matched_gt: set[int] = set() + # Track (path, cap) pairs that had at least one finding match. + found_path_caps: set[tuple[str, str]] = set() - found_keys: set[tuple[str, int, str]] = set() for f in findings: - key_gt = (f.get("path", ""), f.get("line", 0), cap_of(f)) - found_keys.add(key_gt) - cap = cap_of(f) + f_path = f.get("path", "") + f_line = f.get("line", 0) + f_cap = cap_of(f) + cap = f_cap lang = lang_of(f) cell_key = (cap, lang) - if key_gt in gt_true: + matched_idx = None + for idx, gt_entry in enumerate(gt_true): + if (gt_entry["path"] == f_path + and gt_entry["cap"] == f_cap + and abs(gt_entry["line"] - f_line) <= LINE_TOLERANCE + and idx not in matched_gt): + matched_idx = idx + break + if matched_idx is not None: + matched_gt.add(matched_idx) + found_path_caps.add((f_path, f_cap)) cells[cell_key]["tp"] += 1 else: cells[cell_key]["fp"] += 1 - for gt_key in gt_true: - if gt_key not in found_keys: - cap = gt_key[2] + for idx, gt_entry in enumerate(gt_true): + if idx not in matched_gt: + cap = gt_entry["cap"] cells[(cap, "unknown")]["fn"] += 1 result = {