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 = {