mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] sweep after phase 09: 4 deferred items resolved
This commit is contained in:
parent
996bff5983
commit
e9649ea099
5 changed files with 224 additions and 15 deletions
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
66
frontend/src/test/modals/NewScanModal.test.tsx
Normal file
66
frontend/src/test/modals/NewScanModal.test.tsx
Normal file
|
|
@ -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(<NewScanModal open={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Start new scan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls mutateAsync without verify key when checkbox is untouched', async () => {
|
||||
render(<NewScanModal open={true} onClose={vi.fn()} />);
|
||||
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(<NewScanModal open={true} onClose={vi.fn()} />);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
84
scripts/check_corpus_sync.py
Normal file
84
scripts/check_corpus_sync.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue