mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
Dynamic (#77)
This commit is contained in:
parent
55247b7fcd
commit
991c84a1eb
1464 changed files with 225448 additions and 1985 deletions
19
.config/nextest.toml
Normal file
19
.config/nextest.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# nextest configuration
|
||||
#
|
||||
# See https://nexte.st/docs/configuration/ for the full schema.
|
||||
|
||||
# ── Test groups ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# `hostile-input-timing` serialises the two timing-bounded
|
||||
# `hostile_input_tests` cases that pass under nextest in isolation but fail
|
||||
# under the full-suite parallel run on darwin (resource contention from the
|
||||
# other ~4000 tests pushes them past their internal budget). Pinning them to
|
||||
# a single thread within their own group keeps their wall-clock predictable
|
||||
# without slowing the rest of the suite.
|
||||
|
||||
[test-groups]
|
||||
hostile-input-timing = { max-threads = 1 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'binary(hostile_input_tests) and (test(very_long_single_line_parses) or test(many_small_functions_do_not_explode))'
|
||||
test-group = 'hostile-input-timing'
|
||||
103
.github/workflows/ci.yml
vendored
103
.github/workflows/ci.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
|||
branches: ["master"]
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
|
|
@ -197,8 +198,8 @@ jobs:
|
|||
- name: Compile check at MSRV
|
||||
run: cargo check --all-features --tests
|
||||
|
||||
rust-stable-test:
|
||||
name: rust-stable-test
|
||||
rust-stable-test-linux-without-docker:
|
||||
name: rust-stable-test / linux-without-docker
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
|
@ -210,8 +211,59 @@ jobs:
|
|||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Rust tests (stable)
|
||||
run: cargo nextest run --all-features
|
||||
- name: Rust tests (stable, no docker)
|
||||
run: cargo nextest run --no-fail-fast --all-features
|
||||
|
||||
rust-stable-test-linux-with-docker:
|
||||
name: rust-stable-test / linux-with-docker
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Pull language images for sandbox tests
|
||||
run: |
|
||||
docker pull python:3-slim
|
||||
docker pull node:20-slim
|
||||
docker pull eclipse-temurin:21-jre-jammy
|
||||
docker pull php:8-cli
|
||||
|
||||
- name: Smoke-test interpreter availability
|
||||
run: |
|
||||
docker run --rm python:3-slim python3 --version
|
||||
docker run --rm node:20-slim node --version
|
||||
docker run --rm eclipse-temurin:21-jre-jammy java -version
|
||||
docker run --rm php:8-cli php --version
|
||||
|
||||
- name: Rust tests with docker (sandbox escape gate)
|
||||
run: cargo nextest run --no-fail-fast --all-features --test dynamic_sandbox_escape --test dynamic_parity
|
||||
|
||||
escape-positive-control:
|
||||
name: escape-positive-control
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Pull python image
|
||||
run: docker pull python:3-slim
|
||||
|
||||
- name: Escape positive control (gate wiring check)
|
||||
run: |
|
||||
cargo nextest run --no-fail-fast --all-features --test dynamic_sandbox_escape \
|
||||
-- --include-ignored positive_control_cap_sys_admin
|
||||
|
||||
cross-platform-smoke:
|
||||
name: cross-platform-smoke
|
||||
|
|
@ -234,7 +286,7 @@ jobs:
|
|||
run: cargo build --release --all-features
|
||||
|
||||
- name: Smoke tests
|
||||
run: cargo nextest run --all-features --test integration_tests --test pattern_tests --test cli_validation_tests
|
||||
run: cargo nextest run --no-fail-fast --all-features --test integration_tests --test pattern_tests --test cli_validation_tests
|
||||
|
||||
rust-beta-test:
|
||||
name: rust-beta-test
|
||||
|
|
@ -250,7 +302,7 @@ jobs:
|
|||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Rust tests (beta)
|
||||
run: cargo nextest run --all-features
|
||||
run: cargo nextest run --no-fail-fast --all-features
|
||||
|
||||
cargo-package:
|
||||
name: cargo-package
|
||||
|
|
@ -299,16 +351,18 @@ jobs:
|
|||
cache: true
|
||||
cache-key: benchmark-gate-release
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Build benchmark + perf test binaries
|
||||
run: cargo test --release --all-features --test benchmark_test --test perf_tests --no-run
|
||||
run: cargo nextest run --release --all-features --test benchmark_test --test perf_tests --no-run
|
||||
|
||||
- name: Accuracy regression gate (P/R/F1)
|
||||
run: cargo test --release --all-features --test benchmark_test -- --ignored --nocapture benchmark_evaluation
|
||||
run: cargo nextest run --no-fail-fast --release --all-features --test benchmark_test --run-ignored only --no-capture benchmark_evaluation
|
||||
|
||||
- name: Performance regression gate
|
||||
env:
|
||||
NYX_CI_BENCH: "1"
|
||||
run: cargo test --release --all-features --test perf_tests -- --nocapture
|
||||
run: cargo nextest run --no-fail-fast --release --all-features --test perf_tests --no-capture
|
||||
|
||||
- name: Upload benchmark results
|
||||
if: always()
|
||||
|
|
@ -317,3 +371,34 @@ jobs:
|
|||
name: benchmark-results
|
||||
path: tests/benchmark/results/latest.json
|
||||
if-no-files-found: warn
|
||||
|
||||
corpus-marker-audit:
|
||||
name: corpus-marker-audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Marker collision audit (§16.3)
|
||||
run: python3 scripts/corpus_dashboard.py
|
||||
# Exits non-zero if any oracle marker from one cap appears in another
|
||||
# cap's payload bytes. This catches cross-cap oracle collisions that
|
||||
# would cause false-positive confirmed verdicts.
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Corpus unit tests (no_marker_collisions, all_payloads_have_fixture_paths)
|
||||
run: cargo nextest run --no-fail-fast --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
|
||||
|
|
|
|||
167
.github/workflows/corpus_promote.yml
vendored
Normal file
167
.github/workflows/corpus_promote.yml
vendored
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
name: Corpus Promote
|
||||
|
||||
# Weekly automated promotion-PR template.
|
||||
#
|
||||
# Scans fuzz-discovered/ for candidates not yet in src/dynamic/corpus.rs
|
||||
# and opens a PR proposing them for human review (§16.4 — no auto-merge).
|
||||
#
|
||||
# Also runs the marker-collision audit as a hard gate: if any collision is
|
||||
# found the workflow fails rather than proposing the promotion.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Sundays at 09:00 UTC — offset from the fuzz run (06:00 UTC) so
|
||||
# discovered candidates are ready before the promotion job runs.
|
||||
- cron: "0 9 * * 0"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run (print PR body but do not open)"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: corpus-promote
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
name: Propose corpus promotions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# ── Marker collision audit ──────────────────────────────────────────────
|
||||
- name: Marker collision audit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --features dynamic -p nyx-scanner 2>/dev/null || true
|
||||
cd fuzz/dynamic_corpus
|
||||
cargo run -- audit-markers
|
||||
env:
|
||||
RUST_LOG: error
|
||||
|
||||
# ── Discover candidates ─────────────────────────────────────────────────
|
||||
- name: Find promotion candidates
|
||||
id: candidates
|
||||
run: |
|
||||
set -euo pipefail
|
||||
count=0
|
||||
files=""
|
||||
if [ -d fuzz-discovered ]; then
|
||||
while IFS= read -r f; do
|
||||
# Skip .gitkeep, sidecar JSONs, and files already listed in corpus.rs.
|
||||
[[ "$f" == *".gitkeep" ]] && continue
|
||||
[[ "$f" == *".json" ]] && continue
|
||||
bytes=$(xxd -p "$f" | tr -d '\n')
|
||||
if ! grep -q "$bytes" src/dynamic/corpus.rs 2>/dev/null; then
|
||||
count=$((count + 1))
|
||||
files="$files $f"
|
||||
fi
|
||||
done < <(find fuzz-discovered -type f | sort)
|
||||
fi
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
echo "files=$files" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Skip if no new candidates
|
||||
if: steps.candidates.outputs.count == '0'
|
||||
run: |
|
||||
echo "No new candidates found in fuzz-discovered/. Nothing to promote."
|
||||
|
||||
# ── Open promotion PR ───────────────────────────────────────────────────
|
||||
- name: Open promotion PR
|
||||
if: >
|
||||
steps.candidates.outputs.count != '0' &&
|
||||
github.event.inputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CANDIDATE_COUNT: ${{ steps.candidates.outputs.count }}
|
||||
CANDIDATE_FILES: ${{ steps.candidates.outputs.files }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
branch="corpus-promote-$(date +%Y%m%d)"
|
||||
git checkout -b "$branch"
|
||||
|
||||
# Stage candidate files into fuzz-discovered (already there).
|
||||
# The PR body provides the reviewer with everything they need.
|
||||
|
||||
# Build PR body into a temp file to avoid shell re-interpolation of
|
||||
# sidecar JSON content (which may contain backticks or $(...) sequences).
|
||||
body_file=$(mktemp)
|
||||
|
||||
cat > "$body_file" <<'PREAMBLE'
|
||||
## Corpus Promotion Proposal
|
||||
|
||||
This PR was generated automatically by the weekly corpus-promote workflow.
|
||||
It does **not** auto-merge — a human reviewer must approve each candidate
|
||||
before it can land in `src/dynamic/corpus.rs` (§16.4).
|
||||
|
||||
### Candidates
|
||||
|
||||
The following payloads were discovered by the internal mutation fuzzer and
|
||||
confirmed via `sink_hit && oracle_fired` against instrumented fixtures:
|
||||
|
||||
PREAMBLE
|
||||
|
||||
for f in $CANDIDATE_FILES; do
|
||||
sidecar="${f}.json"
|
||||
printf -- '- `%s`\n' "$f" >> "$body_file"
|
||||
if [ -f "$sidecar" ]; then
|
||||
printf ' ```json\n' >> "$body_file"
|
||||
cat "$sidecar" >> "$body_file"
|
||||
printf '\n ```\n' >> "$body_file"
|
||||
fi
|
||||
done
|
||||
|
||||
cat >> "$body_file" <<'CHECKLIST'
|
||||
|
||||
### Review checklist
|
||||
|
||||
- [ ] Bytes are a genuine attack vector, not a fixture artifact
|
||||
- [ ] Oracle marker is unique (no collision with other caps)
|
||||
- [ ] `fixture_paths` updated in `src/dynamic/corpus.rs`
|
||||
- [ ] `since_corpus_version` set to next version
|
||||
- [ ] `CORPUS_VERSION` bumped and bump history updated
|
||||
|
||||
_Generated by corpus_promote.yml — do not auto-merge._
|
||||
CHECKLIST
|
||||
|
||||
git add fuzz-discovered/ || true
|
||||
git diff --cached --quiet || git commit -m "chore: add ${CANDIDATE_COUNT} fuzzer-discovered corpus candidates"
|
||||
|
||||
git push origin "$branch"
|
||||
|
||||
gh pr create \
|
||||
--title "chore(corpus): promote ${CANDIDATE_COUNT} fuzzer-discovered payload(s)" \
|
||||
--body "$(cat "$body_file")" \
|
||||
--base master \
|
||||
--label "corpus-promotion" || true
|
||||
|
||||
rm -f "$body_file"
|
||||
|
||||
- name: Dry run summary
|
||||
if: github.event.inputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Dry run: would promote ${{ steps.candidates.outputs.count }} candidate(s)."
|
||||
echo "Files: ${{ steps.candidates.outputs.files }}"
|
||||
5
.github/workflows/docs.yml
vendored
5
.github/workflows/docs.yml
vendored
|
|
@ -25,6 +25,11 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- name: Cache mdbook
|
||||
id: cache-mdbook
|
||||
uses: actions/cache@v5
|
||||
|
|
|
|||
146
.github/workflows/dynamic.yml
vendored
Normal file
146
.github/workflows/dynamic.yml
vendored
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Phase 29 (Track I): dedicated dynamic-verification matrix.
|
||||
#
|
||||
# Three rows exercise the dynamic harness pipeline (`cargo nextest run
|
||||
# --features dynamic`) under the host configurations the Phase 17–28
|
||||
# tracks documented as supported:
|
||||
#
|
||||
# linux-process-only — Ubuntu host, no docker daemon. Forces the
|
||||
# process backend and exercises the Phase 17
|
||||
# Linux hardening primitives (chroot, seccomp,
|
||||
# unshare, no_new_privs). `libc6-dev` is
|
||||
# installed so the hardening probe + escape
|
||||
# suite can `cc -static`; without it the
|
||||
# chroot-leg of the escape suite skips silently
|
||||
# (Phase 20 follow-up #4 in deferred.md).
|
||||
#
|
||||
# linux-with-docker — Ubuntu host with the runner Docker daemon. Exercises
|
||||
# the docker backend (Phase 19) and the
|
||||
# differential-confirmation parity tests.
|
||||
#
|
||||
# macos — macOS-latest, no docker. Exercises the
|
||||
# Phase-18 `sandbox-exec` primitives plus the
|
||||
# process backend on Darwin. Track-I acceptance
|
||||
# literal: "cargo nextest run --features dynamic
|
||||
# is green on macOS without docker."
|
||||
|
||||
name: dynamic
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux-process-only:
|
||||
name: dynamic / linux-process-only
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Force the process backend even when callers default to Auto so
|
||||
# docker-unavailable paths cannot accidentally hide a regression.
|
||||
NYX_SANDBOX_BACKEND: process
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
# Phase 17 / Phase 20 follow-up: the hardening probe + escape
|
||||
# suite chroot leg need static glibc. Without these packages the
|
||||
# `cc -static probe.c` step in tests/sandbox_hardening_linux.rs +
|
||||
# tests/sandbox_escape_suite.rs falls back to dynamic linking and
|
||||
# the chroot leg silently skips.
|
||||
- name: Install fixture prerequisites (static libc)
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y --no-install-recommends libc6-dev libc-dev-bin
|
||||
|
||||
- name: Smoke-test interpreter availability
|
||||
run: |
|
||||
python3 --version
|
||||
node --version || sudo apt-get install -y --no-install-recommends nodejs
|
||||
ruby --version || true
|
||||
php --version || true
|
||||
|
||||
- name: Dynamic suite (process backend only)
|
||||
run: cargo nextest run --no-fail-fast --features dynamic
|
||||
|
||||
linux-with-docker:
|
||||
name: dynamic / linux-with-docker
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Install fixture prerequisites (static libc)
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y --no-install-recommends libc6-dev libc-dev-bin
|
||||
|
||||
- name: Pull language images for sandbox tests
|
||||
run: |
|
||||
docker pull python:3-slim
|
||||
docker pull node:20-slim
|
||||
docker pull eclipse-temurin:21-jre-jammy
|
||||
docker pull php:8-cli
|
||||
|
||||
- name: Smoke-test docker interpreter availability
|
||||
run: |
|
||||
docker run --rm python:3-slim python3 --version
|
||||
docker run --rm node:20-slim node --version
|
||||
docker run --rm eclipse-temurin:21-jre-jammy java -version
|
||||
docker run --rm php:8-cli php --version
|
||||
|
||||
- name: Dynamic suite (process + docker backends)
|
||||
run: cargo nextest run --no-fail-fast --features dynamic
|
||||
|
||||
macos:
|
||||
name: dynamic / macos
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
# macOS runners ship without docker; force process backend so the
|
||||
# `Auto` resolver in src/dynamic/sandbox.rs cannot accidentally
|
||||
# pick up a stray Lima/Colima daemon and confuse the matrix.
|
||||
NYX_SANDBOX_BACKEND: process
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Smoke-test sandbox-exec availability
|
||||
run: |
|
||||
/usr/bin/sandbox-exec -p '(version 1)(allow default)' /bin/echo ok
|
||||
|
||||
- name: Smoke-test interpreter availability
|
||||
run: |
|
||||
python3 --version
|
||||
node --version
|
||||
ruby --version
|
||||
|
||||
# Phase 29 acceptance literal: "cargo nextest run --features
|
||||
# dynamic is green on macOS without docker (process-only row)."
|
||||
- name: Dynamic suite (macOS, process backend)
|
||||
run: cargo nextest run --no-fail-fast --features dynamic
|
||||
348
.github/workflows/eval.yml
vendored
Normal file
348
.github/workflows/eval.yml
vendored
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
# Real-corpus acceptance (Track R).
|
||||
#
|
||||
# * owasp (Phase 27 / Track R.0): Gate 6 vs a real OWASP BenchmarkJava
|
||||
# checkout (Java).
|
||||
# * jsts (Phase 28 / Track R.1): Gate 7 vs OWASP NodeGoat (Express, .js)
|
||||
# and OWASP Juice Shop (TypeScript, .ts), one matrix row per corpus.
|
||||
# * polyglot (Phase 29 / Track R.2): Gate 8 vs OWASP RailsGoat (Rails, .rb),
|
||||
# DVWA (PHP), DVPWA (aiohttp, .py), gosec (Go) and the RustSec advisory-db
|
||||
# (Rust negative control), one matrix row per corpus.
|
||||
#
|
||||
# Runs on every PR that touches the dynamic verifier (src/dynamic/), the
|
||||
# eval-corpus harness (tests/eval_corpus/), or the gate script itself.
|
||||
#
|
||||
# Each gate enforces, against the committed ground truth:
|
||||
# * verify wall-clock <= 15 min (CI budget; the dev reference is 10 min),
|
||||
# * the per-(cap,lang) budget in tests/eval_corpus/budget.toml,
|
||||
# * per-cap confirmed-rate / precision / recall — hard-gated only for caps
|
||||
# in NYX_*_FLOOR_CAPS (empty by default → published report-only until a
|
||||
# cap Confirms end to end), with destinations >= 40% / >= 0.85 / >= 0.40.
|
||||
#
|
||||
# No corpus is vendored. Each is cloned at a pinned ref and cached so reruns
|
||||
# skip the clone. Before the gate runs, the committed ground truth is
|
||||
# regenerated from its source against the fresh clone and asserted in sync,
|
||||
# and the converter hard-errors on any labelled path missing from the corpus,
|
||||
# so a corpus bump that drifts the labels fails the job loudly.
|
||||
|
||||
name: eval
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- "src/dynamic/**"
|
||||
- "tests/eval_corpus/**"
|
||||
- "scripts/m7_ship_gate.sh"
|
||||
- ".github/workflows/eval.yml"
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- "src/dynamic/**"
|
||||
- "tests/eval_corpus/**"
|
||||
- "scripts/m7_ship_gate.sh"
|
||||
- ".github/workflows/eval.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
owasp:
|
||||
name: eval / owasp-benchmark-v1.2
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Gate 6 self-skips unless this points at a real checkout.
|
||||
NYX_OWASP_CORPUS: ${{ github.workspace }}/.eval-corpus/owasp_benchmark_v1.2
|
||||
# CI wall-clock budget: 20 min. The 2740-file OWASP scan+verify lands
|
||||
# right at the old 15-min ceiling on the hosted runners (observed 900.2s),
|
||||
# so the gate tripped on CI variance alone; 1200s restores headroom. The
|
||||
# dev reference stays 10 min — override locally to tighten.
|
||||
NYX_OWASP_WALLCLOCK_BUDGET_SECONDS: "1200"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
# The Phase 22 Java compile pool drives `com.sun.tools.javac` out of a
|
||||
# warm JDK; temurin 21 ships the compiler module the pool loads.
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Cache OWASP BenchmarkJava (1.2beta)
|
||||
id: cache-owasp
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .eval-corpus/owasp_benchmark_v1.2
|
||||
key: owasp-benchmark-1.2beta
|
||||
|
||||
- name: Clone OWASP BenchmarkJava (1.2beta tag)
|
||||
if: steps.cache-owasp.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
git clone --depth 1 --branch 1.2beta \
|
||||
https://github.com/OWASP-Benchmark/BenchmarkJava \
|
||||
.eval-corpus/owasp_benchmark_v1.2
|
||||
|
||||
# No-compromise guard: the committed ground truth must be exactly what a
|
||||
# fresh conversion of the pinned CSV produces. Catches GT drift (a
|
||||
# corpus bump, a hand-edit) before the gate runs on stale labels.
|
||||
- name: Verify ground truth is in sync with the pinned corpus
|
||||
run: |
|
||||
python3 tests/eval_corpus/owasp_gt_convert.py \
|
||||
--corpus-dir .eval-corpus/owasp_benchmark_v1.2 \
|
||||
--output /tmp/owasp_gt_regen.json
|
||||
python3 - <<'PY'
|
||||
import json, sys
|
||||
committed = json.load(open("tests/eval_corpus/ground_truth/owasp_benchmark_v1.2.json"))
|
||||
regen = json.load(open("/tmp/owasp_gt_regen.json"))
|
||||
if committed != regen:
|
||||
sys.exit("committed ground truth diverges from a fresh conversion of "
|
||||
"the 1.2beta CSV; regenerate with owasp_gt_convert.py")
|
||||
print(f"ground truth in sync: {len(committed)} records")
|
||||
PY
|
||||
|
||||
- name: eval-corpus harness regression tests
|
||||
run: |
|
||||
python3 tests/eval_corpus/test_tabulate_regression.py
|
||||
python3 tests/eval_corpus/test_manifest_gt_convert.py
|
||||
|
||||
- name: Gate 6 — OWASP Benchmark v1.2 acceptance
|
||||
run: scripts/m7_ship_gate.sh --sets owasp
|
||||
|
||||
jsts:
|
||||
name: eval / ${{ matrix.corpus.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
corpus:
|
||||
- name: nodegoat
|
||||
repo: https://github.com/OWASP/NodeGoat
|
||||
# NodeGoat ships no release tags; pin the default branch and let
|
||||
# the cache key hold it stable. The manifest's path layout
|
||||
# (app/, config/) has been constant for years.
|
||||
ref: master
|
||||
env: NYX_NODEGOAT_CORPUS
|
||||
manifest: nodegoat.manifest.toml
|
||||
ground_truth: nodegoat.json
|
||||
- name: juiceshop
|
||||
repo: https://github.com/juice-shop/juice-shop
|
||||
ref: v15.0.0
|
||||
env: NYX_JUICESHOP_CORPUS
|
||||
manifest: juiceshop.manifest.toml
|
||||
ground_truth: juiceshop.json
|
||||
env:
|
||||
# CI wall-clock budget: 15 min. Override locally to tighten.
|
||||
NYX_JSTS_WALLCLOCK_BUDGET_SECONDS: "900"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
# The dynamic verifier's Node build pool (Phase 23) compiles its
|
||||
# harnesses with a real node/npm toolchain.
|
||||
- name: Set up Node 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache ${{ matrix.corpus.name }}
|
||||
id: cache-corpus
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .eval-corpus/${{ matrix.corpus.name }}
|
||||
key: jsts-${{ matrix.corpus.name }}-${{ matrix.corpus.ref }}
|
||||
|
||||
- name: Clone ${{ matrix.corpus.name }} (${{ matrix.corpus.ref }})
|
||||
if: steps.cache-corpus.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
git clone --depth 1 --branch ${{ matrix.corpus.ref }} \
|
||||
${{ matrix.corpus.repo }} \
|
||||
.eval-corpus/${{ matrix.corpus.name }}
|
||||
|
||||
# No-compromise guard: the committed ground truth must be exactly what a
|
||||
# fresh conversion of the curated manifest produces *against this
|
||||
# corpus*. manifest_gt_convert.py hard-errors on any labelled path that
|
||||
# no longer exists in the clone (corpus drift / typo), and the diff
|
||||
# below catches a stale committed JSON.
|
||||
- name: Verify ground truth is in sync with the pinned corpus
|
||||
run: |
|
||||
python3 tests/eval_corpus/manifest_gt_convert.py \
|
||||
--manifest tests/eval_corpus/ground_truth/${{ matrix.corpus.manifest }} \
|
||||
--corpus-dir .eval-corpus/${{ matrix.corpus.name }} \
|
||||
--output /tmp/${{ matrix.corpus.name }}_gt_regen.json
|
||||
python3 - <<'PY'
|
||||
import json, sys
|
||||
name = "${{ matrix.corpus.ground_truth }}"
|
||||
committed = json.load(open(f"tests/eval_corpus/ground_truth/{name}"))
|
||||
regen = json.load(open("/tmp/${{ matrix.corpus.name }}_gt_regen.json"))
|
||||
if committed != regen:
|
||||
sys.exit("committed ground truth diverges from a fresh conversion of "
|
||||
"the manifest against the pinned corpus; regenerate with "
|
||||
"manifest_gt_convert.py")
|
||||
print(f"ground truth in sync: {len(committed)} records")
|
||||
PY
|
||||
|
||||
- name: eval-corpus harness regression tests
|
||||
run: |
|
||||
python3 tests/eval_corpus/test_tabulate_regression.py
|
||||
python3 tests/eval_corpus/test_manifest_gt_convert.py
|
||||
|
||||
- name: Gate 7 — ${{ matrix.corpus.name }} acceptance
|
||||
run: |
|
||||
export ${{ matrix.corpus.env }}="${{ github.workspace }}/.eval-corpus/${{ matrix.corpus.name }}"
|
||||
scripts/m7_ship_gate.sh --sets ${{ matrix.corpus.name }}
|
||||
|
||||
polyglot:
|
||||
name: eval / ${{ matrix.corpus.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
corpus:
|
||||
- name: railsgoat
|
||||
repo: https://github.com/OWASP/railsgoat
|
||||
ref: rails.5.0.0
|
||||
lang: ruby
|
||||
env: NYX_RAILSGOAT_CORPUS
|
||||
manifest: railsgoat.manifest.toml
|
||||
ground_truth: railsgoat.json
|
||||
- name: dvwa
|
||||
repo: https://github.com/digininja/DVWA
|
||||
ref: "2.5"
|
||||
lang: php
|
||||
env: NYX_DVWA_CORPUS
|
||||
manifest: dvwa.manifest.toml
|
||||
ground_truth: dvwa.json
|
||||
- name: dvpwa
|
||||
repo: https://github.com/anxolerd/dvpwa
|
||||
# DVPWA ships no release tags; pin the default branch and let the
|
||||
# cache key hold it stable.
|
||||
ref: master
|
||||
lang: python
|
||||
env: NYX_DVPWA_CORPUS
|
||||
manifest: dvpwa.manifest.toml
|
||||
ground_truth: dvpwa.json
|
||||
- name: gosec
|
||||
repo: https://github.com/securego/gosec
|
||||
ref: v2.26.1
|
||||
lang: go
|
||||
env: NYX_GOSEC_CORPUS
|
||||
manifest: gosec.manifest.toml
|
||||
ground_truth: gosec.json
|
||||
- name: rustsec
|
||||
repo: https://github.com/rustsec/advisory-db
|
||||
# advisory-db ships no release tags; pin the default branch. This
|
||||
# is the Rust NEGATIVE CONTROL (advisory metadata, no scannable
|
||||
# source) — its committed ground truth is empty by construction.
|
||||
ref: main
|
||||
lang: rust
|
||||
env: NYX_RUSTSEC_CORPUS
|
||||
manifest: rustsec.manifest.toml
|
||||
ground_truth: rustsec.json
|
||||
env:
|
||||
# CI wall-clock budget: 15 min. Override locally to tighten.
|
||||
NYX_POLYGLOT_WALLCLOCK_BUDGET_SECONDS: "900"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
# The dynamic verifier's per-language build pool (Phase 22/23) compiles
|
||||
# its harnesses with a real toolchain. Each matrix row sets up only the
|
||||
# toolchain for its corpus's target language; the Rust row needs no extra
|
||||
# step (the rust toolchain above covers it, and advisory-db has no
|
||||
# buildable source anyway).
|
||||
- name: Set up Ruby
|
||||
if: matrix.corpus.lang == 'ruby'
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
|
||||
- name: Set up PHP
|
||||
if: matrix.corpus.lang == 'php'
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.3"
|
||||
|
||||
- name: Set up Python
|
||||
if: matrix.corpus.lang == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Go
|
||||
if: matrix.corpus.lang == 'go'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
|
||||
- name: Cache ${{ matrix.corpus.name }}
|
||||
id: cache-corpus
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .eval-corpus/${{ matrix.corpus.name }}
|
||||
key: polyglot-${{ matrix.corpus.name }}-${{ matrix.corpus.ref }}
|
||||
|
||||
- name: Clone ${{ matrix.corpus.name }} (${{ matrix.corpus.ref }})
|
||||
if: steps.cache-corpus.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
git clone --depth 1 --branch ${{ matrix.corpus.ref }} \
|
||||
${{ matrix.corpus.repo }} \
|
||||
.eval-corpus/${{ matrix.corpus.name }}
|
||||
|
||||
# No-compromise guard: the committed ground truth must be exactly what a
|
||||
# fresh conversion of the curated manifest produces *against this corpus*.
|
||||
# manifest_gt_convert.py hard-errors on any labelled path that no longer
|
||||
# exists in the clone (corpus drift / typo); the diff below catches a
|
||||
# stale committed JSON. For the RustSec negative control the manifest
|
||||
# carries `negative_control = true` and zero entries, so the converter
|
||||
# emits an empty `[]` — still validated against the real clone.
|
||||
- name: Verify ground truth is in sync with the pinned corpus
|
||||
run: |
|
||||
python3 tests/eval_corpus/manifest_gt_convert.py \
|
||||
--manifest tests/eval_corpus/ground_truth/${{ matrix.corpus.manifest }} \
|
||||
--corpus-dir .eval-corpus/${{ matrix.corpus.name }} \
|
||||
--output /tmp/${{ matrix.corpus.name }}_gt_regen.json
|
||||
python3 - <<'PY'
|
||||
import json, sys
|
||||
name = "${{ matrix.corpus.ground_truth }}"
|
||||
committed = json.load(open(f"tests/eval_corpus/ground_truth/{name}"))
|
||||
regen = json.load(open("/tmp/${{ matrix.corpus.name }}_gt_regen.json"))
|
||||
if committed != regen:
|
||||
sys.exit("committed ground truth diverges from a fresh conversion of "
|
||||
"the manifest against the pinned corpus; regenerate with "
|
||||
"manifest_gt_convert.py")
|
||||
print(f"ground truth in sync: {len(committed)} records")
|
||||
PY
|
||||
|
||||
- name: eval-corpus harness regression tests
|
||||
run: |
|
||||
python3 tests/eval_corpus/test_tabulate_regression.py
|
||||
python3 tests/eval_corpus/test_manifest_gt_convert.py
|
||||
|
||||
- name: Gate 8 — ${{ matrix.corpus.name }} acceptance
|
||||
run: |
|
||||
export ${{ matrix.corpus.env }}="${{ github.workspace }}/.eval-corpus/${{ matrix.corpus.name }}"
|
||||
scripts/m7_ship_gate.sh --sets ${{ matrix.corpus.name }}
|
||||
68
.github/workflows/fuzz.yml
vendored
68
.github/workflows/fuzz.yml
vendored
|
|
@ -147,3 +147,71 @@ jobs:
|
|||
path: fuzz/artifacts/${{ matrix.target }}/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
harness-fuzz:
|
||||
name: harness-fuzz-${{ matrix.cap }}
|
||||
runs-on: ubuntu-latest
|
||||
# Run only on schedule and manual dispatch — 50 k iterations per cap is
|
||||
# too slow for PR checks but is the right cadence for weekly corpus growth.
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- cap: sql_query
|
||||
harness: tests/dynamic_fixtures/python/sqli_positive.py
|
||||
- cap: code_exec
|
||||
harness: tests/dynamic_fixtures/python/cmdi_positive.py
|
||||
- cap: file_io
|
||||
harness: tests/dynamic_fixtures/python/fileio_positive.py
|
||||
- cap: ssrf
|
||||
harness: tests/dynamic_fixtures/python/ssrf_positive.py
|
||||
- cap: html_escape
|
||||
harness: tests/dynamic_fixtures/python/xss_positive.py
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: true
|
||||
cache-workspaces: |
|
||||
.
|
||||
fuzz/dynamic_corpus
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build nyx-dynamic-corpus
|
||||
working-directory: fuzz/dynamic_corpus
|
||||
run: cargo build
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Run harness fuzzer — ${{ matrix.cap }}
|
||||
run: |
|
||||
fuzz/dynamic_corpus/target/debug/nyx-dynamic-corpus run \
|
||||
--cap ${{ matrix.cap }} \
|
||||
--spec-hash "ci-${{ matrix.cap }}" \
|
||||
--harness-cmd "python3 ${{ matrix.harness }}" \
|
||||
--iterations 50000 \
|
||||
--output fuzz-discovered
|
||||
|
||||
- name: Upload discovered candidates
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: harness-fuzz-${{ matrix.cap }}-${{ github.run_id }}
|
||||
path: fuzz-discovered/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
|
|
|||
68
.github/workflows/image-builder.yml
vendored
Normal file
68
.github/workflows/image-builder.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: image-builder
|
||||
|
||||
# Phase 19 (Track E.3): daily drift PR.
|
||||
#
|
||||
# Runs `nyx-image-builder build --all` on a Linux runner that has docker
|
||||
# available, captures the rewritten `tools/image-builder/images.toml`, and
|
||||
# opens a PR when any pinned digest changed. The PR is reviewed manually
|
||||
# before merge so a hostile upstream image cannot silently land in
|
||||
# `IMAGE_DIGESTS`.
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 04:23 UTC daily — off-peak for the major upstream registries so
|
||||
# transient pull errors are rare.
|
||||
- cron: "23 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: image-builder
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh-digests:
|
||||
name: refresh image digests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- name: Verify docker is reachable
|
||||
run: docker info
|
||||
|
||||
- name: Build pinned-digest catalogue
|
||||
run: |
|
||||
cargo run -F image-builder --bin nyx-image-builder -- build --all
|
||||
|
||||
- name: Verify catalogue against local pulls
|
||||
run: |
|
||||
cargo run -F image-builder --bin nyx-image-builder -- verify
|
||||
|
||||
- name: Open PR on drift
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "image-builder: refresh pinned digests"
|
||||
title: "image-builder: refresh pinned digests"
|
||||
body: |
|
||||
Automated digest refresh by `nyx-image-builder build --all`.
|
||||
|
||||
The CI job pulled every base image in
|
||||
`tools/image-builder/images.toml`, captured the resolved
|
||||
`sha256:` digest, and wrote it back into the file. Review
|
||||
the diff before merging — a hostile upstream image would
|
||||
show up here as an unexpected digest change.
|
||||
branch: image-builder/refresh-digests
|
||||
base: master
|
||||
delete-branch: true
|
||||
labels: |
|
||||
image-builder
|
||||
automation
|
||||
11
.github/workflows/release-build.yml
vendored
11
.github/workflows/release-build.yml
vendored
|
|
@ -110,7 +110,12 @@ jobs:
|
|||
BIN_PATH=target/$TARGET/release/$BIN$EXT
|
||||
mkdir -p dist
|
||||
ARCHIVE=$BIN-$TARGET.zip
|
||||
zip -9 "dist/$ARCHIVE" "$BIN_PATH" THIRDPARTY-LICENSES.html LICENSE* COPYING*
|
||||
files=("$BIN_PATH" THIRDPARTY-LICENSES.html)
|
||||
shopt -s nullglob
|
||||
license_files=(LICENSE* COPYING*)
|
||||
shopt -u nullglob
|
||||
files+=("${license_files[@]}")
|
||||
zip -9 "dist/$ARCHIVE" "${files[@]}"
|
||||
echo "ASSET=$ARCHIVE" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Package (Windows)
|
||||
|
|
@ -123,9 +128,11 @@ jobs:
|
|||
$BinPath = "target/$Target/release/$Bin$Ext"
|
||||
New-Item -ItemType Directory -Path dist -Force | Out-Null
|
||||
$Archive = "$Bin-$Target.zip"
|
||||
$LicenseFiles = @(Get-ChildItem -Path 'LICENSE*', 'COPYING*' -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName })
|
||||
$Files = @($BinPath, 'THIRDPARTY-LICENSES.html') + $LicenseFiles
|
||||
|
||||
Compress-Archive `
|
||||
-Path $BinPath, 'THIRDPARTY-LICENSES.html', 'LICENSE*', 'COPYING*' `
|
||||
-Path $Files `
|
||||
-DestinationPath "dist/$Archive" `
|
||||
-CompressionLevel Optimal
|
||||
|
||||
|
|
|
|||
104
.github/workflows/repro-bare.yml
vendored
Normal file
104
.github/workflows/repro-bare.yml
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Replay every tree-committed dynamic repro bundle with host language
|
||||
# toolchains blocked so we catch regressions where a bundle silently
|
||||
# depends on an interpreter the operator does not have.
|
||||
#
|
||||
# The setup step prepends deny-list wrappers for python3, node, ruby,
|
||||
# php, and Java so the only toolchain the bundle can use is the docker
|
||||
# daemon. reproduce.sh in --docker mode pulls the pinned base image
|
||||
# (via docker_pull.sh) and runs the harness inside the container; if the
|
||||
# bundle accidentally relied on a host interpreter the run falls over
|
||||
# before the sentinel check.
|
||||
#
|
||||
# Adding a new fixture: extend the `matrix.fixture` list with the new
|
||||
# `tests/repro_fixtures/<toolchain_id>/<spec_hash>` path. The bundle
|
||||
# must already exist on disk, see tests/repro_fixture_bundles.rs for
|
||||
# the regeneration recipe.
|
||||
|
||||
name: repro-bare
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
bare-image-replay:
|
||||
name: repro-bare / ${{ matrix.fixture }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
fixture:
|
||||
- tests/repro_fixtures/python-3.11/repro
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Block host language toolchains
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Do not mutate the hosted runner image. ubuntu-latest carries
|
||||
# preinstalled and cached language runtimes, and apt package
|
||||
# relationships can shift underneath us as the image is updated.
|
||||
# A PATH-level deny layer gives this job the bare-host semantics it
|
||||
# needs without depending on apt being able to uninstall core bits.
|
||||
deny_dir="${RUNNER_TEMP}/nyx-deny-toolchains"
|
||||
mkdir -p "$deny_dir"
|
||||
for exe in \
|
||||
python python3 python3.10 python3.11 python3.12 python3.13 python3.14 \
|
||||
node npm npx corepack \
|
||||
ruby gem bundle \
|
||||
php \
|
||||
java javac jar
|
||||
do
|
||||
{
|
||||
printf '%s\n' '#!/bin/sh'
|
||||
printf '%s\n' 'echo "error: host language toolchain is disabled in repro-bare; use the Docker replay path" >&2'
|
||||
printf '%s\n' 'exit 127'
|
||||
} > "${deny_dir}/${exe}"
|
||||
chmod +x "${deny_dir}/${exe}"
|
||||
done
|
||||
|
||||
export PATH="${deny_dir}:${PATH}"
|
||||
echo "${deny_dir}" >> "${GITHUB_PATH}"
|
||||
hash -r 2>/dev/null || true
|
||||
|
||||
# Confirm the deny layer is active — surface the failure here
|
||||
# rather than inside reproduce.sh where it would look like a
|
||||
# bundle bug.
|
||||
for exe in python3 node ruby php java; do
|
||||
resolved="$(command -v "${exe}" || true)"
|
||||
if [ "${resolved}" != "${deny_dir}/${exe}" ]; then
|
||||
echo "error: ${exe} deny wrapper is not first on PATH (got ${resolved:-not found})" >&2
|
||||
exit 1
|
||||
fi
|
||||
if "${exe}" --version >/dev/null 2>&1; then
|
||||
echo "error: ${exe} still runs after host-toolchain block" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "error: docker is no longer reachable after host-toolchain block" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify docker is reachable
|
||||
run: docker info
|
||||
|
||||
- name: Pre-pull pinned image
|
||||
working-directory: ${{ matrix.fixture }}
|
||||
run: ./docker_pull.sh
|
||||
|
||||
- name: Replay bundle via docker
|
||||
working-directory: ${{ matrix.fixture }}
|
||||
run: ./reproduce.sh --docker
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,18 +1,22 @@
|
|||
/target
|
||||
/fuzz/target
|
||||
/fuzz/corpus
|
||||
/fuzz/dynamic_corpus/target
|
||||
/fuzz/artifacts
|
||||
/.idea
|
||||
/frontend/node_modules
|
||||
/src/server/assets/dist
|
||||
/marketing
|
||||
/.nyx
|
||||
/.nyx-build-cache
|
||||
/logs
|
||||
/book
|
||||
.DS_Store
|
||||
.z3-trace
|
||||
.pitboss
|
||||
.eval-corpus
|
||||
.node_modules-target
|
||||
node_modules
|
||||
__pycache__/
|
||||
*.pyc
|
||||
tools/sb-trace/*.trace.raw
|
||||
|
|
|
|||
99
CHANGELOG.md
99
CHANGELOG.md
|
|
@ -2,6 +2,95 @@
|
|||
|
||||
All notable changes to Nyx are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). For where Nyx is going, see the [Roadmap](ROADMAP.md).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.0] - 2026-06-01
|
||||
|
||||
The dynamic-verification release. An attack-surface map, a sandboxed dynamic verifier, a framework adapter registry that grounds both, the per-language build infrastructure that makes per-finding verification affordable at corpus scale, and the first real-corpus acceptance gates.
|
||||
|
||||
The attack-surface map and chain composer turn the flat finding list into a route-to-sink graph. The dynamic verifier re-runs every Medium-or-higher finding against a payload corpus and stamps a Confirmed / PartiallyConfirmed / NotConfirmed / Inconclusive / Unsupported verdict on each. The adapter registry (130+ entries across 8 languages) covers HTTP, message-broker, scheduled-job, GraphQL, WebSocket, middleware, and migration entry points. Per-language build pools and copy-on-write workdirs hold the with-verify wall-clock to within 1.5x of a static-only scan.
|
||||
|
||||
### Attack-surface map
|
||||
|
||||
- **`nyx surface` subcommand.** Prints the project's entry points, datastores, external services, and dangerous local sinks as text, JSON, Graphviz `dot`, or rendered SVG. Loads the persisted `SurfaceMap` from the most recent indexed scan when available, or rebuilds inline from source. `--build` forces a full pass-1 + call-graph walk so DataStore / ExternalService / DangerousLocal nodes populate on an unscanned project.
|
||||
- **Surface page in `nyx serve`.** New `SurfacePage` renders the same graph in the browser UI, with ELK layout, sidebar navigation, and a wide-canvas SVG viewer. Persists alongside the index so the frontend reloads without a rescan.
|
||||
- **Chain findings.** `ChainFinding` records connect a route entry point to a downstream sink via the call graph + surface map. The composer scores `(impact × evidence)` per chain, queues the top-N for composite reverification, and wires the result into `findings.json` / SARIF / the dashboard. Chains rank above isolated findings.
|
||||
|
||||
### Framework adapter registry
|
||||
|
||||
`src/dynamic/framework/` ships a `FrameworkAdapter` trait with concrete adapters across 8 languages (116 entries today, growing per release). Each adapter binds a route / handler / consumer pattern to a `FrameworkBinding` so the surface map and dynamic verifier can locate entry points without re-walking the AST.
|
||||
|
||||
- **HTTP routers.** Flask, Django, FastAPI, Starlette (Python); Express, Koa, NestJS, Fastify (JS/TS); Spring, Quarkus, Micronaut, Jakarta Servlet (Java); Gin, Echo, Fiber, Chi (Go); Axum, Actix, Rocket, Warp (Rust); Rails, Sinatra, Hanami (Ruby); Laravel, Symfony, CodeIgniter (PHP).
|
||||
- **New `EntryKind` variants.** `ClassMethod`, `MessageHandler`, `ScheduledJob`, `GraphQLResolver`, `WebSocket`, `Middleware`, `Migration` join the existing `RouteHandler` / `Function` set so the surface map shows non-HTTP entry surfaces.
|
||||
- **Message broker handlers.** Kafka, AWS SQS, Google Pub/Sub, NATS, and RabbitMQ consumers across Python, Node, Java, and Go.
|
||||
- **Scheduled jobs.** Celery (Python), Sidekiq (Ruby), Quartz (Java), plain cron expression recognition.
|
||||
- **GraphQL resolvers.** Apollo, Relay, gqlgen, Juniper, Graphene.
|
||||
- **WebSocket handlers.** ws, Socket.IO, ActionCable, Django Channels.
|
||||
- **Middleware + migrations.** Express, Laravel, Spring, Django, Rails middleware; Django, Flask, Laravel, Rails, Prisma, Sequelize migration scripts.
|
||||
- **Sanitizer-aware adapter strengthening.** Every XXE, header-injection, open-redirect, SSTI, LDAP, XPath, deserialization, crypto, and data-exfiltration adapter rejects bindings when the surrounding source visibly hardens the parser (`disallow-doctype-decl`, `resolve_entities=False`, `libxml_disable_entity_loader`), routes the value through a known encoder (`LdapEncoder.filterEncode`, `escape_filter_chars`, `ldap_escape`), swaps a weak primitive for a CSPRNG (`secrets.token_bytes`, `crypto.randomBytes`, `SecureRandom`), or validates the destination host through an allowlist. Cuts adapter FPs without losing the genuinely dangerous calls.
|
||||
|
||||
### Dynamic verification
|
||||
|
||||
- **`nyx scan --verify`.** Every finding with `Confidence >= Medium` is re-executed inside a sandboxed harness against a curated payload corpus. The verdict (`Confirmed` / `NotConfirmed` / `Inconclusive` / `Unsupported`) lands on `Evidence.dynamic_verdict` and shows up in console output, JSON, SARIF, and the dashboard via a new `VerdictBadge` component on the finding detail page.
|
||||
- **Backends.** In-process on Linux with `Standard` / `Strict` hardening (namespace unshare, chroot, RLIMIT cap, seccomp filter), in-process on macOS via `sandbox-exec` with a profile-per-policy wrap, Docker with a published image-builder catalogue, and a Firecracker trait stub for future microVM execution. The Docker backend ships native binary support for Rust and Go so harnesses no longer need to drag a toolchain into every image.
|
||||
- **Language coverage.** Per-language harness emitters for Python, JS/TS, Go, Java, PHP, Ruby, Rust, C, and C++. Stub harness intercepts SQL, HTTP, Redis, and filesystem boundaries so the verdict reflects the sink, not the network. The `JSON_PARSE`, `UNAUTHORIZED_ID`, and `DATA_EXFIL` cap dispatchers are wired into every emitter that ships these caps (Python, JS, TS, Go, Java, PHP, Ruby, Rust), so the verdict pipeline closes the loop on each cap end-to-end rather than per-language piecemeal.
|
||||
- **Abstract-interpretation and symex sanitizer suppression.** Symbolic execution and the interval/string abstract domain are now consulted at verdict time, so a payload that the static engine would call dangerous but symex can prove never reaches the sink lands as NotConfirmed.
|
||||
- **Guard-aware verdicts.** When a known input-validation or output-sanitization middleware sits in front of a Confirmed sink (Spring `@PreAuthorize`, Express `helmet`, Nest `@UseGuards`, Django `@permission_classes`, and the per-language registry in `src/dynamic/framework/auth_markers.rs`), the verdict demotes to `ConfirmedWithKnownGuard` and the guard names land on `differential.known_guards`. Authentication-only filters do not trigger the demotion since they do not mitigate injection.
|
||||
- **Repro bundles.** Every verified finding writes a hermetic bundle to `~/.cache/nyx/dynamic/repro/<spec_hash>/` with `reproduce.sh`, `expected/{verdict.json,outcome.json,trace.jsonl}`, and a `docker_pull.sh` when the toolchain is pinned in `tools/image-builder/images.toml`. `--verbose` flushes the per-step `VerifyTrace` to stderr for live triage.
|
||||
- **Real-engine harness paths.** LDAP injection routes through an embedded LDAPv3 BER server, exercised from Java via JNDI `InitialDirContext` and from Python and PHP via pure-stdlib BER clients. XPath injection runs against the live parser in each language: Java `javax.xml.xpath`, PHP `DOMXPath`, JS `xpath` npm, Python `lxml`. `Cap::CRYPTO` lands a `WeakKey` probe across Python, Go, Java, PHP, and Rust that flags sub-2^16 keys produced by non-CSPRNG sources. A new `HeaderSmuggledInWire` oracle predicate catches CRLF smuggling on hand-rolled raw-socket HTTP servers (Python `http.server`, Node `net`, Rust `std::net::TcpListener`) where framework-level CRLF strip cannot intervene.
|
||||
- **Differential rule v2 and partial confirmations.** A finding confirms when *any* vulnerable payload in the set fires and *every* paired benign control stays clean, replacing the strict pair-wise rule so a single missing control no longer downgrades a confirmable finding. A new `PartiallyConfirmed` verdict marks findings where the sink is reached but the exploit chain does not complete (no marker written, no callback observed), so engine work can ratchet without the tool overstating what it proved.
|
||||
- **Spec derivation v2.** Every derivation strategy now runs and is scored on flow-step depth, framework binding, cross-file source resolution via `GlobalSummaries`, and payload availability; the highest-scoring candidate wins and the runner-up ranking lands in the trace so engine gaps stay visible. Cross-file seeding walks the call graph (max depth 5) until a `Source` step or framework binding is found. New `EntryKind` adapters auto-recover the entry surface from framework decorators and annotations.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Per-language build pools.** A warm `javac` daemon compiles batched harness sources in one long-lived JVM (Track O headline, Phase 22); Node, PHP, Ruby, Go, Rust, C, and C++ reuse shared module / package / object caches; Python layers a read-only venv per `requirements_hash` with a warmed bytecode cache. Target per-finding harness build: P50 ≤ 200ms hot, ≤ 1.5s cold. Pools self-skip when a toolchain is absent so toolchain-less CI rows stay green.
|
||||
- **Copy-on-write workdirs.** Per-finding workdir setup uses `clonefile` on macOS and `reflink` / `copy_file_range` on Linux instead of copying every harness file, cutting setup cost to single-digit milliseconds.
|
||||
- **Cap-routed concurrency lanes.** The verifier worker pool splits into per-cap lanes (`SSRF: 8`, `DESERIALIZE: 2`, `CRYPTO: 1`, and so on) so a slow harness for one cap cannot head-of-line block fast ones.
|
||||
- **Ship-gate budgets.** Gate 3 holds the with-verify / static-only wall-clock ratio at ≤ 1.5x on `benches/fixtures/`; Gate 6 holds the Java OWASP Benchmark `--verify` run at ≤ 15 min on CI / ≤ 10 min on the dev reference machine.
|
||||
|
||||
### Determinism, policy, telemetry
|
||||
|
||||
- **YAML policy deny list.** `src/policy.rs` is consulted before harness build. Network egress, filesystem writes outside the sandbox root, and process spawns can be denied per-rule; deny decisions land in the trace, redacted via the shared scrubber.
|
||||
- **Seeded RNG.** `dynamic::rand::SpecRng` is seeded from each `HarnessSpec` hash so two runs of the same spec produce identical payloads. `scripts/check_no_unseeded_rand.sh` audits the tree for unseeded `rand` usage on every CI run.
|
||||
- **`VerifyTrace` observability.** Every per-step decision (probe selection, payload mutation, oracle check, deny verdict) writes to the trace stream and the repro bundle.
|
||||
- **Schema-versioned telemetry.** `events.jsonl` carries `schema_version`, `nyx_version`, `corpus_version`, `kind`, and `ts` on every envelope. PII and secret scrubbing runs on every persisted artefact via `src/utils/redact.rs`.
|
||||
- **`NYX_NO_TELEMETRY=1`** disables event persistence outright.
|
||||
|
||||
### CVE corpus and ground truth
|
||||
|
||||
- **New `Cap` corpora.** Vulnerable + patched fixtures landed for the seven new cap classes (LDAP injection, XPath injection, header injection, open redirect, SSTI, XXE, prototype pollution) plus deserialization, crypto, JSON parsing, unauthorized-id, and data exfiltration. Every cap now carries at least one positive / negative / adversarial / unsupported fixture quad per supported language.
|
||||
- **OWASP Benchmark v1.2 importer.** `tests/eval_corpus/owasp_gt_convert.py` converts the OWASP Java Benchmark expected-results manifest into Nyx ground truth and lands a 16k-line `owasp_benchmark_v1.2.json` for evaluation.
|
||||
- **NIST SARD importer.** `tests/eval_corpus/sard_gt_convert.py` converts SARD test cases into the same format so cross-dataset recall numbers stay comparable.
|
||||
- **Evaluation corpus tooling.** `tests/eval_corpus/run_full.sh` runs the Nyx benchmark, OWASP Benchmark, and NIST SARD evaluation sets and writes `tests/eval_corpus/results.json`. `tests/eval_corpus/report.py` and `tabulate.py` produce the per-cap and per-language summary used to track coverage and accuracy.
|
||||
- **Real-corpus acceptance gates.** `scripts/m7_ship_gate.sh` adds Gate 6 (Java OWASP Benchmark v1.2), Gate 7 (NodeGoat + Juice Shop), and Gate 8 (RailsGoat, DVWA, DVPWA, gosec, RustSec). Each row enforces the per-`(cap, lang)` budget in `tests/eval_corpus/budget.toml` and publishes per-cap precision / recall / confirmed-rate against a committed ground truth. The corpora are not vendored; each row self-skips unless its `NYX_<NAME>_CORPUS` points at a checkout.
|
||||
- **Per-spec cryptographic canary.** Every oracle marker is now derived from `BLAKE3(spec_hash || run_nonce)` rather than a fixed literal, so markers are unique per finding, collision-resistant against ambient harness output, and never leak to the host. A compile-time audit rejects any new ad-hoc canary.
|
||||
|
||||
### Engine
|
||||
|
||||
- **DB fast-fail preflight.** `Indexer::init` reads the first 16 bytes of any candidate SQLite file and rejects anything without the standard `SQLite format 3\0` magic. Stops a misnamed JSON / text file from corrupting the index path with a SQLite error halfway through migration.
|
||||
- **Symbolic-execution coverage.** Symex now recognises a wider set of string operations (`substr`, `replace`, `to_lower`, `to_upper`, `trim`, `strlen`) per the value/transfer pipeline, and the abstract-interpretation framework reasons about interval and prefix/suffix string facts during the dynamic verdict pass.
|
||||
|
||||
### CLI
|
||||
|
||||
- **`nyx scan --verify`** (enabled by default in standard builds) and `--backend {auto,process,docker}` select the dynamic-verification harness. `--no-verify` skips verification for a single run without changing config.
|
||||
- **`nyx scan --harden {standard,strict}`** picks the process-backend hardening profile. `standard` is no-new-privs plus a memory rlimit on Linux. `strict` layers namespace unshare, chroot to the workdir, and a default-deny seccomp filter on Linux, or wraps the harness with `sandbox-exec` on macOS.
|
||||
- **Patch-validation CI mode.** `--baseline FILE` reads a previous scan's JSON (or a stripped `.nyx/baseline.json` written by `--baseline-write`) and diffs it against the current scan on `stable_hash`, emitting `New` / `Resolved` / `FlippedConfirmed` / `FlippedNotConfirmed` transitions. `--gate {no-new-confirmed,resolve-all-confirmed}` exits non-zero when the diff violates the policy so CI fails the build instead of merging an unreviewed regression. The stripped baseline carries only `stable_hash`, `dynamic_verdict`, `severity`, `path`, and `rule_id`, so persisting it between scans does not leak source.
|
||||
- **`nyx scan --verify-all-confidence`** drops the Medium cutoff and re-verifies everything.
|
||||
- **`nyx scan --unsafe-sandbox`** disables hardening (development only, never for CI).
|
||||
- **`nyx verify-feedback <finding_id> --wrong <reason> | --right`** records a correction or confirmation for a finding's verdict in the local telemetry log.
|
||||
- **`nyx scan --explain-engine`** prints the effective engine configuration and exits without scanning.
|
||||
- **`nyx surface`** (described above) with `--format {text,json,dot,svg}` and `--build`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Surface page** with ELK auto-layout and the shared node-style palette.
|
||||
- **Verdict badge** on finding detail, plus a dynamic-verdict section that surfaces the verdict, the payload that triggered it, and a link to the repro bundle.
|
||||
- **Scan compare** gains a dynamic-verdict diff column so two scans can be compared on what was confirmed versus what was downgraded.
|
||||
|
||||
### License
|
||||
|
||||
- **Internal license grants documentation** at `LICENSE-GRANTS.md`. Grant 1 covers Nyctos derived works. The repo stays GPL-3.0-or-later; the grants document scope of internal product licensing.
|
||||
|
||||
## [0.7.0] - 2026-05-11
|
||||
|
||||
A focused release that adds seven new vulnerability classes, ships two SSA sidecars for XML and XPath parser hardening, deepens cross-file authorization for FastAPI, trims roughly a thousand auth false positives on Go DAO helpers along with the dominant Hibernate Criteria SQL cluster, and runs a performance pass on the auth extractor, SCCP, and the global summaries map. A `nyx rules list` CLI surfaces the rule registry, the web UI gets a brand-aligned visual refresh, and the CVE corpus grows across Python, PHP, JavaScript, and C.
|
||||
|
|
@ -46,7 +135,7 @@ A focused release that adds seven new vulnerability classes, ships two SSA sidec
|
|||
- **FastAPI cross-file `include_router` dependency tracking.** `auth_analysis/router_facts.rs` captures per-file router declarations (`<router> = X(deps=[…])`) and `<parent>.include_router(<child_module>.<child_var>)` edges in pass 1, persists them into `GlobalSummaries::router_facts_by_module`, and resolves them into the active file's `AuthorizationModel::cross_file_router_deps` at pass 2 entry. Transitive lifts (grandparent to parent to child) handled by iterative index walk. Module identity is the file basename without `.py`. Closes the airflow execution-API shape where a child router lives in `routes/task_instances.py` and its auth is declared on the parent in `routes/__init__.py`.
|
||||
- **FastAPI router-level `dependencies=[...]` propagation.** Module-level `router = APIRouter(dependencies=[Security(...)])` is pre-walked once per file and merged onto every `@<router>.<verb>(...)` route attached in the same file. Closes airflow execution-API routes that re-use a single `ti_id_router` declared once at module scope.
|
||||
- **FastAPI `Security(callable, scopes=[...])` recognised distinctly from `Depends(callable)`.** Scoped Security promotes the synthetic `AuthCheck` to `AuthCheckKind::Other` (route-level scope-checked authorization), not Login. New scope-tracking boolean threaded through `expand_decorator_calls` and `extract_fastapi_dependencies`.
|
||||
- **Caller-scope IPA: same-file route-handler-to-helper auth lift.** `apply_caller_scope_propagation` walks every non-route helper unit; if its in-file callers are non-empty AND every caller is itself an authorized route handler (route-level non-Login auth check) or already authorized via this same propagation, the caller's checks lift onto the helper as synthetic `is_route_level=true` `AuthCheck`s. Iterated to a small fixpoint so transitive helper chains (route to mid_helper to leaf_helper) are covered. Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers. Cross-file equivalent deferred. Closes the dominant FastAPI / Django / Flask "route authenticates via decorator/dependency, then delegates to a private helper that performs the sink" FP shape on sentry / saleor / airflow.
|
||||
- **Caller-scope IPA: same-file route-handler-to-helper auth lift.** `apply_caller_scope_propagation` walks every non-route helper unit; if its in-file callers are non-empty AND every caller is itself an authorized route handler (route-level non-Login auth check) or already authorized via this same propagation, the caller's checks lift onto the helper as synthetic `is_route_level=true` `AuthCheck`s. Iterated to a small fixpoint so transitive helper chains (route to mid_helper to leaf_helper) are covered. Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers. Cross-file lifting is not implemented. Closes the dominant FastAPI / Django / Flask "route authenticates via decorator/dependency, then delegates to a private helper that performs the sink" FP shape on sentry / saleor / airflow.
|
||||
- **Go DAO-helper id-scalar precision pass.** For non-route Go units, a parameter whose declared type is a bounded primitive scalar (`int64`, `uint32`, `string`, `bool`, `byte`, `rune`, `float64`, …) and whose name is id-shaped (`id`, `*Id`, `*_id`, `*ids`) is dropped from `unit.params` before ownership-check evaluation. Real Go HTTP handlers always carry a framework-request-typed param (`*http.Request`, `*gin.Context`, `echo.Context`, `*fiber.Ctx`); per-framework route extractors set `include_id_like_typed=true` so id-shaped path params survive on real routes. Mirrors the existing Python `is_python_id_like_typed_param` filter. Closes ~957 `go.auth.missing_ownership_check` findings on gitea backend DAO helpers (`func GetRunByRepoAndID(ctx, repoID, runID int64)`, `func DeleteRunner(ctx, id int64)`, the entire `models/...` layer where the ownership check sits in the calling route handler) and equivalent shapes in minio / Go ORM codebases.
|
||||
- **Bare-callee verb-name fallback gate.** `list(...)`, `filter(...)`, `update(...)`, `create_audit_entry(...)`, `update_coding_agent_state(...)` (no receiver dot at all) no longer classify as `DbMutation` / `DbCrossTenantRead` via the loose verb-name fallback. Real ORM/DB calls carry a receiver (`User.find(id)`, `Model.objects.filter`, `repo.save(x)`); a bare `list(events)` is the Python builtin and `filter(fn, xs)` is `Iterable.filter`. New helper `receiver_is_simple_chain(callee)` requires a non-chained receiver dot. The realtime / outbound / cache prefix dispatches still match by chain root.
|
||||
|
||||
|
|
@ -80,7 +169,7 @@ Per-language label rules expanded for the seven new caps.
|
|||
|
||||
### CVE corpus
|
||||
|
||||
- **C.** CVE-2017-1000117 (git argv injection via `ssh://-oProxyCommand=…`) vulnerable + patched fixtures under `tests/benchmark/cve_corpus/c/CVE-2017-1000117/`. Three-layer engine gap deferred (array-element taint propagation, `c.cmdi.exec*` AST patterns, dash-prefix-byte sanitizer recognition).
|
||||
- **C.** CVE-2017-1000117 (git argv injection via `ssh://-oProxyCommand=…`) vulnerable + patched fixtures under `tests/benchmark/cve_corpus/c/CVE-2017-1000117/`. Known remaining gap: array-element taint propagation, `c.cmdi.exec*` AST patterns, and dash-prefix-byte sanitizer recognition.
|
||||
- **Python.** CVE-2023-6568 (mlflow reflected XSS), CVE-2024-21513 (langchain SQL / Jinja), CVE-2024-23334 (aiohttp static-file path traversal) vulnerable + patched fixtures.
|
||||
- **PHP.** CVE-2026-33486 (roadiz/documents SSRF) vulnerable + patched fixtures.
|
||||
- **JavaScript.** CVE-2026-42353 (i18next-http-middleware path traversal) vulnerable + patched fixtures.
|
||||
|
|
@ -159,6 +248,9 @@ A precision pass on auth and resource analysis plus three fresh CVE corpus pairs
|
|||
- Short-circuit branch condition CFG nodes now mirror `condition_vars` into `taint.uses`, so `apply_branch_predicates` interns the variable for short-circuit-decomposed validators (`if (x == null || !regex.matcher(x).matches()) throw`). Without this, the per-disjunct cond nodes built via `build_condition_chain` silently no-opped and `x` never reached `validated_must` on the surviving branch.
|
||||
- Go `goqu.L(s)` and `goqu.Lit(s)` raw-SQL literal builders modeled as `SQL_QUERY` sinks. Safe siblings (`goqu.I` identifier, `goqu.C` column, `goqu.T` table, `goqu.V` parameterised value, `goqu.SUM`, `goqu.COUNT`, …) stay unlabeled. Gin source list extended with the array-returning siblings of the existing scalar helpers: `c.QueryArray`, `c.GetQueryArray`, `c.PostFormArray`, `c.GetPostFormArray`. Closes CVE-2026-41422 (daptin: `c.QueryArray("column")` → `goqu.L(project)` with the loop variable lifted through `for _, project := range columns`). Vulnerable + patched Go corpus pair under `tests/benchmark/cve_corpus/go/CVE-2026-41422/`.
|
||||
- Go `for ident := range iter` def-use lifting. The `range_clause` child of `for_statement` is now consulted when `left`/`right` aren't direct fields of the `for` node, so taint from the iterable reaches the loop binding. Required for the daptin CVE shape above.
|
||||
- Java `enhanced_for_statement`, PHP `foreach`, and Ruby `for` def-use lifting, completing the loop forms the Go `range_clause` fix above started. The `Kind::For` def-use arm only knew the JS/Python `left`/`right` pair and Go's `range_clause`; Java carries the binding on `name` and the iterable on `value`, Ruby's `for` on `pattern`/`value`, and PHP's `foreach` keeps both as unnamed children split by the `as` keyword, so none recorded the loop variable as a define and taint on the iterable never reached the binding (`for (Cookie c : req.getCookies()) { … c.getValue() … }` lost the flow at `c`). Each form now folds onto the shared define/use path. Lifts Java OWASP Benchmark recall: path_traversal 0.21 → 0.32, sqli 0.16 → 0.28, cmdi 0.04 → 0.08.
|
||||
- Iterable-expression classification for the loop forms above. The loop node is classified against its iterable text, so a source-returning iterable (`req.getCookies()`, `req.getParameterValues("v")`, `$_GET['list']`) lands a `Source` on the loop node and the binding inherits its taint, the same rewrite JS/Python `for … of` / `for … in` already had. Subscript iterables (`$_GET['x']`, `params[:list]`) classify on their base object since sources key on the base name, not the index.
|
||||
- Java iterable-returning request accessors modeled as sources: `getParameterValues`, `getParameterMap`, `getParameterNames`, `getHeaders`, `getHeaderNames`. The `getParameter` / `getHeader` matchers are word-boundary suffix matches and never covered the plural collection variants that feed for-each loops (`for (String s : req.getParameterValues("v"))`). The dominant OWASP Benchmark vulnerable-source shape.
|
||||
- Rust format-string named-argument lifting (`format!("...{x}...")`, stable since 1.58). Identifiers captured by `{name}` / `{name:fmt-spec}` are pulled into the call's `uses` for known format-style macros: `format`, `print`/`println`, `eprint`/`eprintln`, `write`/`writeln`, `panic`, `format_args`, `assert`/`debug_assert`, `todo`, `unimplemented`, `unreachable`, plus log-crate severity macros (`info`, `warn`, `error`, `debug`, `trace`). Recursive descent through one or two layers of expression wrapping (`format!("{x}").to_owned()`, RHS chained method calls). Without this, taint stopped at the macro boundary. `let q = format!("...{x}...")` carried no `x` because the identifier lives in format-string bytes rather than as a separate AST argument node. Mirrors the Python f-string lifter.
|
||||
- Rust CVE corpus extended. CVE-2023-42456, CVE-2024-32884, CVE-2025-53549 vulnerable + patched fixtures under `tests/benchmark/cve_corpus/rust/`.
|
||||
- Java lambda shorthand recognised by `extract_param_meta`. `lambda_expression`'s `parameters` field as a bare `identifier` (`cmd -> …`) or as an `inferred_parameters` wrapper around identifiers (`(a, b) -> …`) was not matching the formal_parameter / spread_parameter kinds in `PARAM_CONFIG`, so the lambda appeared parameterless and the SSA pipeline treated its formals as closure captures. Mirrors the JS/TS arrow shorthand path.
|
||||
|
|
@ -169,6 +261,7 @@ A precision pass on auth and resource analysis plus three fresh CVE corpus pairs
|
|||
|
||||
### Fixed (false positives)
|
||||
|
||||
- `cfg-unguarded-sink` parameter-only trace no longer clears a sink argument whose reaching definition is a loop binding. Once the loop variable resolves to its iterable (the def-use lifting above), a `foreach ($param as $v) { sink($v) }` element looked like a bare `sink($p)` wrapper pass-through and the structural finding was dropped. A loop element over a parameter collection is not wrapper plumbing, so the finding survives for loop-bound sink arguments; literal-keyed arrays stay suppressed through `sink_arg_uses_safe_foreach_key`. Keeps the negative case in `fp_guard_php_foreach_safe_literal_keys` firing.
|
||||
- Go `unit_has_user_input_evidence` framework-request-name allow-list narrowed for Go. `ctx`, `context`, `info`, `body`, `path`, `payload`, `dto`, `form`, `query` are no longer treated as user-input indicators on Go: in Go these are `context.Context` (cancellation/value-bag from the stdlib) or struct-pointer payload params (`info *PackageInfo`, `opts *FooOptions`), not request bindings. Go HTTP frameworks bind the request to per-framework typed params (`r *http.Request`, `c *gin.Context`, `c echo.Context`, `c *fiber.Ctx`); these arrive at the gate via `RouteHandler` kind or the type-aware param filter below. Stdlib `req` / `request` (the `*http.Request` convention) preserved. Other languages keep the broader allow-list.
|
||||
- Go param collection drops `ctx context.Context` and `ctx context.CancelFunc` parameters entirely rather than seeding their names into `unit.params`. Tree-sitter-go's `parameter_declaration` exposes `name` and `type` as named fields; descend only into `name` so type-segment identifiers don't pollute the param-name set (`info *PackageInfo` no longer contributes `PackageInfo`). Together with the allow-list narrowing above, closes ~1900 `go.auth.missing_ownership_check` findings on gitea backend helpers whose only "user-input evidence" was the ubiquitous `ctx context.Context` first param.
|
||||
- Ruby controller method visibility + filter-callback gate. Methods marked `private` (bare `private` directive, targeted `private :foo, :bar`, or `protected`) and Rails filter callback targets (`before_action`, `after_action`, `around_action`, their `prepend_*` / `append_*` / `skip_*` siblings, and the legacy `*_filter` aliases) are no longer emitted as `Function` units. Visibility tracking is class-body source-order with two directive forms (bare toggles default visibility, targeted explicitly marks named methods). Block-form filters (`before_action do … end`) carry no symbol arg and are correctly ignored. Closes mastodon / diaspora `rb.auth.missing_ownership_check` flood on `set_X` row-fetch helpers used as `before_action` callbacks.
|
||||
|
|
@ -318,7 +411,7 @@ The biggest release since launch. The taint engine was rebuilt on top of an SSA
|
|||
|
||||
- Replaced the legacy `app.js` with a React + Vite + TypeScript SPA.
|
||||
- Interactive graph workspace for CFG and call-graph views (Graphology + ELK + Sigma) with neighborhood reduction and a full-page inspector.
|
||||
- Triage UI with database-backed decisions (true positive, false positive, deferred, suppressed) and `.nyx/triage.json` round-trip.
|
||||
- Triage UI with database-backed decisions (true positive, false positive, accepted risk, suppressed) and `.nyx/triage.json` round-trip.
|
||||
- Scan history, rules management, and finding detail panels with evidence and flow visualization.
|
||||
- Vitest browser-side test suite wired into CI.
|
||||
- Bumped to React 19, Vite 8, TypeScript 6.0, ESLint 10, `@vitejs/plugin-react` 6, with aligned `@types/react*`.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating.
|
|||
|
||||
- **Rust 1.88+** (edition 2024)
|
||||
- Git
|
||||
- **Node 20+** — only if you touch the browser UI under `frontend/` (the
|
||||
`nyx serve` web app). Pure-Rust changes do not need it.
|
||||
|
||||
### Building
|
||||
|
||||
|
|
@ -43,13 +45,29 @@ cargo install --path . # Install as `nyx` binary
|
|||
|
||||
### Running Quality Checks
|
||||
|
||||
The fastest way to reproduce CI locally is the bundled script — it runs the same
|
||||
commands CI runs (fmt, Clippy, tests, and the frontend checks):
|
||||
|
||||
```bash
|
||||
cargo test --bin nyx # Unit tests (inline in modules)
|
||||
cargo clippy --all -- -D warnings # Lint, treats warnings as errors
|
||||
cargo fmt # Format code
|
||||
cargo fmt -- --check # Check formatting without modifying
|
||||
./scripts/check.sh # Mirror CI: fmt + clippy + tests (+ frontend)
|
||||
./scripts/check.sh --rust-only # Skip the frontend checks
|
||||
./scripts/fix.sh # Auto-fix: cargo fmt + clippy --fix + prettier/eslint
|
||||
```
|
||||
|
||||
Or run the steps individually:
|
||||
|
||||
```bash
|
||||
cargo test --all-features # Tests, incl. tests/ integration suite
|
||||
cargo clippy --all-targets --all-features -- -D warnings # Lint, warnings = errors
|
||||
cargo fmt # Format code
|
||||
cargo fmt -- --check # Check formatting without modifying
|
||||
```
|
||||
|
||||
> **Match CI exactly.** CI lints and tests with `--all-targets --all-features`.
|
||||
> The older `cargo test --bin nyx` / `cargo clippy --all` commands skip the
|
||||
> `tests/` integration suite and feature-gated code, so they can pass locally
|
||||
> while CI fails. Prefer `./scripts/check.sh`.
|
||||
|
||||
> **Note**: The first build downloads and compiles tree-sitter grammars for all 10 languages. Subsequent builds are faster.
|
||||
|
||||
### Benchmarks
|
||||
|
|
@ -64,6 +82,12 @@ Benchmark fixtures live in `benches/fixtures/`. Criterion produces HTML reports
|
|||
|
||||
## Project Layout
|
||||
|
||||
> **New here?** [`docs/how-it-works.md`](docs/how-it-works.md) walks the analysis
|
||||
> pipeline end to end (with a diagram), and [`docs/detectors/taint.md`](docs/detectors/taint.md)
|
||||
> covers the taint engine. The easiest first contribution is usually a new AST
|
||||
> pattern (see [below](#how-to-add-a-new-ast-pattern)) — small, self-contained,
|
||||
> and well templated.
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs CLI entry point
|
||||
|
|
@ -260,12 +284,13 @@ Adding a new language requires changes across several modules. Use an existing l
|
|||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
### Tests
|
||||
|
||||
All tests are inline `#[test]` blocks inside source modules. Run them with:
|
||||
Unit tests are inline `#[test]` blocks inside source modules; integration tests
|
||||
live under `tests/`. Run everything the way CI does:
|
||||
|
||||
```bash
|
||||
cargo test --bin nyx
|
||||
cargo test --all-features
|
||||
```
|
||||
|
||||
### What to Test
|
||||
|
|
@ -280,7 +305,7 @@ cargo test --bin nyx
|
|||
CI runs Clippy with strict settings. Before submitting:
|
||||
|
||||
```bash
|
||||
cargo clippy --all -- -D warnings
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -293,10 +318,10 @@ First-time contributors are welcome. If you are unsure where to start, open an i
|
|||
|
||||
2. **Keep PRs focused**. One logical change per PR.
|
||||
|
||||
3. **Ensure CI passes**:
|
||||
3. **Ensure CI passes** — run `./scripts/check.sh` (mirrors CI), or the steps individually:
|
||||
```bash
|
||||
cargo test --bin nyx
|
||||
cargo clippy --all -- -D warnings
|
||||
cargo test --all-features
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo fmt -- --check
|
||||
```
|
||||
|
||||
|
|
@ -340,7 +365,7 @@ We welcome well-motivated feature proposals. Please describe:
|
|||
|
||||
1. Update version in `Cargo.toml`.
|
||||
2. Update `CHANGELOG.md` with the new version section.
|
||||
3. Run full test suite: `cargo test --bin nyx && cargo clippy --all -- -D warnings`.
|
||||
3. Run full checks: `./scripts/check.sh` (or `cargo test --all-features && cargo clippy --all-targets --all-features -- -D warnings`).
|
||||
4. Create a git tag: `git tag v0.x.y`.
|
||||
5. Push tag: `git push origin v0.x.y`.
|
||||
6. CI builds release binaries and publishes to crates.io.
|
||||
|
|
|
|||
55
Cargo.lock
generated
55
Cargo.lock
generated
|
|
@ -637,6 +637,12 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
|
|
@ -741,6 +747,25 @@ dependencies = [
|
|||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
|
|
@ -1136,12 +1161,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "nyx-scanner"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"assert_cmd",
|
||||
"axum",
|
||||
"bitflags",
|
||||
"blake3",
|
||||
"bytes",
|
||||
"bytesize",
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -1151,6 +1177,8 @@ dependencies = [
|
|||
"dashmap",
|
||||
"directories",
|
||||
"glob",
|
||||
"h2",
|
||||
"http",
|
||||
"ignore",
|
||||
"indicatif",
|
||||
"num_cpus",
|
||||
|
|
@ -1159,6 +1187,7 @@ dependencies = [
|
|||
"petgraph",
|
||||
"phf",
|
||||
"predicates",
|
||||
"prost",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"rayon",
|
||||
|
|
@ -1413,6 +1442,29 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
|
|
@ -1925,6 +1977,7 @@ version = "1.52.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
|
|
|
|||
49
Cargo.toml
49
Cargo.toml
|
|
@ -1,14 +1,14 @@
|
|||
[package]
|
||||
name = "nyx-scanner"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
description = "A multi-language static analysis tool for detecting security vulnerabilities"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Eli Peter <elicpeter@example.com>"]
|
||||
homepage = "https://github.com/elicpeter/nyx"
|
||||
homepage = "https://nyxsec.dev/scanner"
|
||||
repository = "https://github.com/elicpeter/nyx"
|
||||
documentation = "https://elicpeter.github.io/nyx/"
|
||||
documentation = "https://nyxsec.dev/docs/nyx/"
|
||||
keywords = ["security", "vulnerability", "scanner", "static-analysis", "cli"]
|
||||
categories = ["security", "command-line-utilities", "development-tools", "parser-implementations", "text-processing"]
|
||||
readme = "README.md"
|
||||
|
|
@ -41,11 +41,26 @@ features = ["serve"]
|
|||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
|
||||
[features]
|
||||
default = ["serve"]
|
||||
default = ["serve", "dynamic"]
|
||||
serve = ["dep:axum", "dep:tokio", "dep:tokio-stream", "dep:tower-http"]
|
||||
smt = ["dep:z3", "z3/bundled"]
|
||||
smt-system-z3 = ["dep:z3"]
|
||||
docgen = []
|
||||
# Dynamic verification layer: builds harnesses from findings, runs them in a
|
||||
# sandbox, reports back whether the sink fires.
|
||||
dynamic = ["dep:bytes", "dep:h2", "dep:http", "dep:prost", "dep:tempfile", "dep:tokio"]
|
||||
# Phase 19 (Track E.3): the `nyx-image-builder` helper binary that builds
|
||||
# and pins per-toolchain Docker images. Gated so it does not bloat the
|
||||
# default `nyx` build with extra TOML-write logic CI-only operators need.
|
||||
image-builder = []
|
||||
# Phase 20 (Track E.4): the firecracker VM backend. Off by default so
|
||||
# the standard build pulls in zero Firecracker-related code; turning it
|
||||
# on adds the `firecracker.rs` backend module and exposes
|
||||
# `SandboxBackend::Firecracker` to callers. When the feature is on but
|
||||
# the `firecracker` binary is absent on PATH, the backend returns
|
||||
# `SandboxError::BackendUnavailable(SandboxBackend::Firecracker)` so the
|
||||
# verifier can route around it cleanly.
|
||||
firecracker = ["dynamic"]
|
||||
|
||||
[lib]
|
||||
name = "nyx_scanner"
|
||||
|
|
@ -60,10 +75,20 @@ name = "nyx-docgen"
|
|||
path = "tools/docgen/main.rs"
|
||||
required-features = ["docgen"]
|
||||
|
||||
[[bin]]
|
||||
name = "nyx-image-builder"
|
||||
path = "tools/image-builder/main.rs"
|
||||
required-features = ["image-builder"]
|
||||
|
||||
[[bench]]
|
||||
name = "scan_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "dynamic_bench"
|
||||
harness = false
|
||||
required-features = []
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
criterion = { version = "0.8.2", features = ["html_reports"] }
|
||||
|
|
@ -116,10 +141,24 @@ smallvec = { version = "1.15.1", features = ["serde"] }
|
|||
rustc-hash = "2.1.2"
|
||||
uuid = { version = "1.23.1", features = ["v4"] }
|
||||
axum = { version = "0.8.9", optional = true }
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "signal", "sync"], optional = true }
|
||||
bytes = { version = "1.11.0", optional = true }
|
||||
h2 = { version = "0.4.14", optional = true }
|
||||
http = { version = "1.3.1", optional = true }
|
||||
prost = { version = "0.14.3", optional = true }
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "signal", "sync", "net", "io-util"], optional = true }
|
||||
tokio-stream = { version = "0.1.18", features = ["sync"], optional = true }
|
||||
tower-http = { version = "0.6.10", features = ["cors", "compression-gzip", "trace", "set-header", "limit"], optional = true }
|
||||
z3 = { version = "0.20.0", optional = true}
|
||||
tempfile = { version = "3.27.0", optional = true }
|
||||
|
||||
[lints.clippy]
|
||||
# Allowed project-wide instead of per-file. The vast majority of
|
||||
# `collapsible_if` hits are `if let Some(x) = .. { if cond { .. } }` patterns
|
||||
# whose only "fix" is to collapse into a let-chain, which hurts readability on
|
||||
# the complex extractor expressions throughout the engine. Keeping the decision
|
||||
# here means the rationale lives in one place and new files inherit it
|
||||
# automatically rather than re-declaring `#![allow(clippy::collapsible_if)]`.
|
||||
collapsible_if = "allow"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
|
|
|||
89
LICENSE-GRANTS.md
Normal file
89
LICENSE-GRANTS.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Internal License Grants
|
||||
|
||||
This file records dual-licensing grants the copyright holder of Nyx has issued
|
||||
beyond the public GPL-3.0-or-later release.
|
||||
|
||||
Nyx ships publicly under GPL-3.0-or-later. That license continues to apply to
|
||||
every public release on GitHub, crates.io, and any other channel. The grants
|
||||
recorded here are separate, private licenses from the copyright holder to
|
||||
specific projects. They do not modify the public GPL terms and they are not
|
||||
transferable to third parties.
|
||||
|
||||
The right to issue these grants is preserved in `CLA.md` Section 4
|
||||
(Relicensing Right):
|
||||
|
||||
> [The contributor] grants the Project and any entity that maintains or
|
||||
> succeeds it the right to relicense Your Contribution, in whole or in part,
|
||||
> under terms other than the Project's current license (currently
|
||||
> GPL-3.0-or-later), where necessary to support the long-term sustainability,
|
||||
> distribution, and evolution of the Project.
|
||||
|
||||
The copyright holder is the sole author of every Contribution to Nyx
|
||||
(verifiable via `git log`). The CLA covers any future external Contributions.
|
||||
The copyright holder may therefore grant any party, including projects owned
|
||||
by the same copyright holder, a license to use Nyx under terms other than
|
||||
GPL-3.0-or-later, without affecting the public GPL release.
|
||||
|
||||
## How forks are affected
|
||||
|
||||
A third-party fork of Nyctos that obtains the Nyctos source under PolyForm
|
||||
Small Business 1.0.0 (or any successor source-available license) does not
|
||||
acquire any rights to Nyx beyond the public GPL-3.0-or-later terms. The
|
||||
internal grant below is project-to-project and non-transferable. Anyone
|
||||
redistributing a binary that statically or dynamically links the `nyx` crate
|
||||
must comply with the GPL on the `nyx` portion of the work. GPL is viral
|
||||
copyleft on distribution. Only the copyright holder may issue further
|
||||
dual-licensing grants.
|
||||
|
||||
---
|
||||
|
||||
## Grant Register
|
||||
|
||||
### Grant 1: Nyctos
|
||||
|
||||
| Field | Value |
|
||||
|---|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Grantor | Eli Peter, sole copyright holder of Nyx as of the effective date |
|
||||
| Grantee | The Nyctos project (`Nyctos` daemon, web UI, and accompanying tooling). Repository: `nyctos` |
|
||||
| Effective date | 2026-05-17 |
|
||||
| Scope | All Nyx source code, documentation, fixtures, build artefacts, and binaries (the "Licensed Material") in any version released as of the effective date or thereafter, plus any future modifications the Grantor authors or accepts under the CLA |
|
||||
| Permitted uses | (a) static or dynamic linking of the Licensed Material into the Nyctos daemon; (b) modification of the Licensed Material as required for Nyctos integration; (c) redistribution of the Licensed Material as part of the Nyctos distribution; (d) sublicensing the Licensed Material to end users of Nyctos solely under whatever license terms Nyctos itself is distributed under (currently PolyForm Small Business 1.0.0, or a separately negotiated commercial license) |
|
||||
| Restrictions | (a) this grant does not modify, supersede, or revoke the public GPL-3.0-or-later release of Nyx; (b) this grant is non-transferable; only the Nyctos project, owned by the Grantor, may exercise it; (c) any third-party fork of Nyctos must obtain Nyx under the public GPL terms unless it negotiates a separate grant from the Grantor; (d) attribution of Nyx authorship must be preserved in any redistribution per the CLA's moral-rights waiver |
|
||||
| Duration | Perpetual and irrevocable, subject only to the Grantee maintaining ownership-or-control by the Grantor. If the Nyctos project is sold, assigned, or otherwise transferred to a third party, this grant terminates and the new owner must negotiate a separate license |
|
||||
| Sublicensing of the grant itself | Not permitted. The Grantee may distribute Nyx as part of Nyctos to end users under Nyctos's outward terms, but the Grantee may not grant any other project the right to use Nyx outside the public GPL terms |
|
||||
| Governing law | Same as Nyx CLA |
|
||||
|
||||
---
|
||||
|
||||
## Adding future grants
|
||||
|
||||
New grants follow the same format as Grant 1. Append a new section
|
||||
(`### Grant N: <recipient name>`) below the existing entries and commit to
|
||||
the Nyx repository. Grants are append-only. Revisions land as superseding
|
||||
entries with their own date, not as edits to the original.
|
||||
|
||||
Grants the Grantor anticipates issuing in the future include:
|
||||
|
||||
- Commercial-license SKU grants to individual customers of Nyctos that
|
||||
exceed the PolyForm Small Business threshold. These will be issued
|
||||
per-customer under a separate Nyx Commercial License contract.
|
||||
- Stewardship-transition grants if the project is ever handed off (for
|
||||
example, to a foundation). These would be a single grant to the receiving
|
||||
entity.
|
||||
|
||||
The Grantor reserves the right to refuse to issue any grant.
|
||||
|
||||
---
|
||||
|
||||
## What this file is NOT
|
||||
|
||||
- It is not a redistribution license. Third parties cannot rely on it to use
|
||||
Nyx outside the public GPL terms.
|
||||
- It is not a Contributor License Agreement. `CLA.md` covers contribution
|
||||
terms separately.
|
||||
- It is not a public-facing license file. The canonical public license for
|
||||
Nyx is `LICENSE` (GPL-3.0-or-later).
|
||||
|
||||
---
|
||||
|
||||
Copyright (c) 2026 Eli Peter. All rights reserved.
|
||||
73
README.md
73
README.md
|
|
@ -1,13 +1,13 @@
|
|||
<div align="center">
|
||||
<img src="assets/nyx-wordmark.svg" alt="nyx" height="110"/>
|
||||
<img src="assets/nyx-readme-header.png" alt="NYX" width="640"/>
|
||||
|
||||
**A local-first security scanner with a browser UI. Scan your repo and triage in your browser, with no cloud and no account.**
|
||||
**A local-first security scanner with sandboxed dynamic verification and a browser UI. Scan your repo and triage in your browser, with no cloud and no account.**
|
||||
|
||||
[](https://crates.io/crates/nyx-scanner)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://github.com/elicpeter/nyx/actions)
|
||||
[](https://elicpeter.github.io/nyx/)
|
||||
[](https://nyxscan.dev/docs/)
|
||||
|
||||
English · [简体中文](./README.zh-CN.md)
|
||||
</div>
|
||||
|
|
@ -18,7 +18,7 @@ English · [简体中文](./README.zh-CN.md)
|
|||
|
||||
## Scan locally, browse locally
|
||||
|
||||
Nyx runs a cross-language taint analysis on your repository, then serves the results to a React UI bound to `127.0.0.1`. You get a finding list with severity, evidence, and a step-by-step **flow visualiser** that walks the dataflow from source → sanitizer → sink. Triage decisions persist to `.nyx/triage.json`, which commits alongside your code so the team shares one triage state.
|
||||
Nyx runs cross-language taint analysis on your repository, then verifies Medium or higher confidence findings by running small sandboxed harnesses against the real code. Results are served to a React UI bound to `127.0.0.1`. You get severity, static evidence, dynamic verdicts, and a step-by-step **flow visualiser** that walks the dataflow from source → sanitizer → sink. Triage decisions persist to `.nyx/triage.json`, which commits alongside your code so the team shares one triage state.
|
||||
|
||||
```bash
|
||||
cargo install nyx-scanner
|
||||
|
|
@ -26,7 +26,7 @@ nyx scan # runs the analyzer, caches findings in .nyx/
|
|||
nyx serve # opens http://localhost:9700 in your browser
|
||||
```
|
||||
|
||||
Everything stays on your machine: loopback-only bind, host-header enforcement, CSRF on every mutation, no telemetry, no login.
|
||||
Everything stays on your machine: loopback-only bind, host-header enforcement, CSRF on every mutation, no remote telemetry, no login.
|
||||
|
||||
<p align="center"><img src="assets/screenshots/overview.png" alt="Overview dashboard for a small JS app: Health Score C 78 with the five-component breakdown (Severity pressure, Confidence quality, Trend, Triage coverage, Regression resistance), 3 findings detected, OWASP A03 and A02 buckets, confidence distribution and issue category bars, top affected files" width="900"/></p>
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ Everything stays on your machine: loopback-only bind, host-header enforcement, C
|
|||
|---|---|
|
||||
| **Overview** | Dashboard: finding counts by severity, top offenders, engine profile summary |
|
||||
| **Findings** | Browsable list with severity badges, triage status, rule filter, language filter |
|
||||
| **Finding detail** | Flow-path visualiser with numbered steps (source → sanitizer → sink), code snippets, evidence, cross-file markers, triage dropdown |
|
||||
| **Finding detail** | Flow-path visualiser with numbered steps (source → sanitizer → sink), dynamic verdicts, code snippets, evidence, cross-file markers, triage dropdown |
|
||||
| **Triage** | Bulk update states (open, investigating, fixed, false_positive, accepted_risk, suppressed), audit trail, import/export JSON |
|
||||
| **Explorer** | File tree with per-file symbol list and finding overlay |
|
||||
| **Scans** | Run history, metrics, diff two scans to see what changed |
|
||||
|
|
@ -46,7 +46,7 @@ Everything stays on your machine: loopback-only bind, host-header enforcement, C
|
|||
| **Config** | Live config editor; reload without restart |
|
||||
|
||||
|
||||
`nyx serve` flags: `--port <N>` (default `9700`), `--host <addr>` (loopback only: `127.0.0.1`, `localhost`, or `::1`), `--no-browser`. See `[server]` in `nyx.conf` for persistent settings, and the [Browser UI guide](https://elicpeter.github.io/nyx/serve.html) for the page-by-page UI tour and security model.
|
||||
`nyx serve` flags: `--port <N>` (default `9700`), `--host <addr>` (loopback only: `127.0.0.1`, `localhost`, or `::1`), `--no-browser`. See `[server]` in `nyx.conf` for persistent settings, and the [Browser UI guide](https://nyxscan.dev/docs/serve.html) for the page-by-page UI tour and security model.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -71,12 +71,12 @@ nyx scan --mode ast
|
|||
nyx scan --engine-profile deep
|
||||
```
|
||||
|
||||
Forward cross-file taint runs in every profile. Symex and the demand-driven backwards walk are opt-in. Turn them on either via `--engine-profile deep`, or individually (`--symex`, `--backwards-analysis`). See the [CLI reference](https://elicpeter.github.io/nyx/cli.html#engine-depth-profile) for the full toggle matrix.
|
||||
Forward cross-file taint runs in every profile. Symex and the demand-driven backwards walk are opt-in. Turn them on either via `--engine-profile deep`, or individually (`--symex`, `--backwards-analysis`). See the [CLI reference](https://nyxscan.dev/docs/cli.html#engine-depth-profile) for the full toggle matrix.
|
||||
|
||||
### GitHub Action
|
||||
|
||||
```yaml
|
||||
- uses: elicpeter/nyx@v0.7.0
|
||||
- uses: elicpeter/nyx@v0.8.0
|
||||
with:
|
||||
format: sarif
|
||||
fail-on: MEDIUM
|
||||
|
|
@ -117,7 +117,7 @@ Requires stable Rust 1.88+. The frontend is compiled and embedded in the binary
|
|||
|
||||
## Languages
|
||||
|
||||
All 10 languages parse via tree-sitter and run through the full pipeline, but rule depth and engine coverage are uneven. Benchmark F1 on the 507-case corpus at [`tests/benchmark/ground_truth.json`](tests/benchmark/ground_truth.json) is 100% across all ten languages, so F1 alone no longer separates the tiers. Tiering reflects rule depth, gated-sink coverage, and structural idioms the synthetic corpus does not fully stress:
|
||||
All 10 languages parse via tree-sitter and run through the full pipeline, but rule depth and engine coverage are uneven. Benchmark F1 on the synthetic corpus at [`tests/benchmark/ground_truth.json`](tests/benchmark/ground_truth.json) is 100% across all ten languages at the last measured baseline (see [`tests/benchmark/RESULTS.md`](tests/benchmark/RESULTS.md)), so F1 alone no longer separates the tiers. Tiering reflects rule depth, gated-sink coverage, and structural idioms the synthetic corpus does not fully stress:
|
||||
|
||||
| Tier | Languages | F1 | Use as a CI gate? |
|
||||
|---|---|---|---|
|
||||
|
|
@ -125,7 +125,7 @@ All 10 languages parse via tree-sitter and run through the full pipeline, but ru
|
|||
| **Beta** | Java, PHP, Ruby, Rust, Go | 100% | Yes, with light FP triage |
|
||||
| **Preview** | C, C++ | 100% on synthetic corpus | No. STL container flow, builder chains, and inline class member functions are tracked, but deep pointer aliasing and function pointers are not. Pair with clang-tidy or Clang Static Analyzer |
|
||||
|
||||
Aggregate rule-level F1: 100.0% (P=1.000, R=1.000). All real-CVE fixtures fire and the corpus carries zero open FPs. Per-dimension detail and known blind spots live on the [Language maturity page](https://elicpeter.github.io/nyx/language-maturity.html).
|
||||
All real-CVE fixtures fire and the corpus carries zero open FPs at the recorded baseline (P=R=F1=1.000). Per-dimension detail and known blind spots live on the [Language maturity page](https://nyxscan.dev/docs/language-maturity.html).
|
||||
|
||||
### Validated against real CVEs
|
||||
|
||||
|
|
@ -183,12 +183,45 @@ Fixtures live under [`tests/benchmark/cve_corpus/`](tests/benchmark/cve_corpus/)
|
|||
|
||||
Two passes over the filesystem, with an optional SQLite index to skip unchanged files:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Repo["Repository files"] --> Pass1["Pass 1 per file<br/>tree-sitter, CFG, SSA"]
|
||||
Pass1 --> Summaries["Function summaries<br/>sources, sinks, sanitizers, points-to"]
|
||||
Summaries --> Index["SQLite index<br/>optional incremental cache"]
|
||||
Index --> Pass2["Pass 2 cross-file<br/>global summaries, k=1 inline, SCC fixpoint"]
|
||||
Pass2 --> Rank["Rank and dedupe<br/>severity, evidence, exploitability"]
|
||||
Rank --> Verify["Dynamic verification<br/>sandboxed harnesses, verdicts"]
|
||||
Verify --> Output["Console, JSON, SARIF<br/>and browser UI"]
|
||||
```
|
||||
|
||||
1. **Pass 1**: parse each file via tree-sitter, build an intra-procedural CFG (petgraph), lower to pruned SSA (Cytron phi insertion over dominance frontiers), and export per-function summaries (source/sanitizer/sink caps, taint transforms, points-to, callees).
|
||||
2. **Summary merge**: union all per-file summaries into a `GlobalSummaries` map.
|
||||
3. **Pass 2**: re-analyze each file with cross-file context under bounded context sensitivity (k=1 inlining for intra-file callees, SCC fixpoint capped at 64 iterations, and summary fallback for callees above the inline body-size cap). A forward dataflow worklist propagates taint through the SSA lattice with guaranteed convergence. Call-graph SCCs iterate to fixed-point (within the cap) so mutually recursive functions get accurate summaries.
|
||||
4. **Rank, dedupe, emit**: findings are scored by severity × evidence strength × source-kind exploitability, then emitted to console, JSON, or SARIF.
|
||||
4. **Rank, dedupe, verify, emit**: findings are scored by severity × evidence strength × source-kind exploitability. Medium or higher confidence findings are dynamically verified by default, then results are emitted to console, JSON, SARIF, and the browser UI.
|
||||
|
||||
Detector families: taint (cross-file source→sink, with cap-specific rule classes for SQLi, XSS, command/code exec, deserialization, SSRF, path traversal, format string, crypto, LDAP injection, XPath injection, HTTP header / response splitting, open redirect, server-side template injection, XXE, prototype pollution, data exfiltration, and the auth fold-in), CFG structural (auth gaps, unguarded sinks, resource leaks), state model (use-after-close, double-close, must-leak, unauthed-access), AST patterns (tree-sitter structural match). Full detector docs: [Detectors](https://elicpeter.github.io/nyx/detectors.html).
|
||||
Detector families: taint (cross-file source→sink, with cap-specific rule classes for SQLi, XSS, command/code exec, deserialization, SSRF, path traversal, format string, crypto, LDAP injection, XPath injection, HTTP header / response splitting, open redirect, server-side template injection, XXE, prototype pollution, data exfiltration, and the auth fold-in), CFG structural (auth gaps, unguarded sinks, resource leaks), state model (use-after-close, double-close, must-leak, unauthed-access), AST patterns (tree-sitter structural match). Full detector docs: [Detectors](https://nyxscan.dev/docs/detectors.html).
|
||||
|
||||
---
|
||||
|
||||
## Verify findings dynamically
|
||||
|
||||
Static analysis says a sink is reachable. Dynamic verification tries to prove it. With `--verify` (on by default), Nyx builds a small harness around each Medium-or-higher finding, runs it in a sandbox against a curated payload corpus, and stamps a verdict onto the finding.
|
||||
|
||||
```bash
|
||||
nyx scan --verify # build + run a harness per finding (default)
|
||||
nyx scan --no-verify # static analysis only, for fast local loops
|
||||
```
|
||||
|
||||
A finding is **Confirmed** only when an attacker-controlled payload fires the sink *and* a paired benign control stays clean. That differential rule, plus behavioral oracles (a template that renders `49`, a deserializer that resolves a gadget class, a redirect that leaves the origin), keeps the verifier from confirming on an echoed string. Sinks behind a recognized guard demote to `ConfirmedWithKnownGuard`; sinks reached without a completed exploit chain land as `PartiallyConfirmed`.
|
||||
|
||||
Coverage spans 18 verifiable capability classes and 120+ registered adapters across all ten languages (Flask, Django, Express, NestJS, Spring, Rails, Laravel, Gin, Axum, and more), with per-language build pools and copy-on-write workdirs to keep the per-finding cost low. Confirmed findings write a hermetic repro bundle with a `reproduce.sh`. Runs are deterministic: every payload is seeded from the spec hash.
|
||||
|
||||
```bash
|
||||
# CI: fail the build if a new Confirmed finding appears vs. a baseline
|
||||
nyx scan --baseline .nyx/baseline.json --gate no-new-confirmed
|
||||
```
|
||||
|
||||
Backends: Docker (preferred, network-blocked by default) or an in-process runner with `--harden {standard,strict}`. Full matrix, oracle list, and limitations: [Dynamic verification](https://nyxscan.dev/docs/dynamic.html).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -213,13 +246,13 @@ kind = "sanitizer"
|
|||
cap = "html_escape"
|
||||
```
|
||||
|
||||
Or add rules interactively: `nyx config add-rule --lang javascript --matcher escapeHtml --kind sanitizer --cap html_escape`. Caps: `env_var`, `html_escape`, `shell_escape`, `url_encode`, `json_parse`, `file_io`, `fmt_string`, `sql_query`, `deserialize`, `ssrf`, `data_exfil`, `code_exec`, `crypto`, `unauthorized_id`, `ldap_injection`, `xpath_injection`, `header_injection`, `open_redirect`, `ssti`, `xxe`, `prototype_pollution`, `all`. Full schema: [Configuration](https://elicpeter.github.io/nyx/configuration.html). Run `nyx rules list` to browse the registry from the terminal.
|
||||
Or add rules interactively: `nyx config add-rule --lang javascript --matcher escapeHtml --kind sanitizer --cap html_escape`. Caps: `env_var`, `html_escape`, `shell_escape`, `url_encode`, `json_parse`, `file_io`, `fmt_string`, `sql_query`, `deserialize`, `ssrf`, `data_exfil`, `code_exec`, `crypto`, `unauthorized_id`, `ldap_injection`, `xpath_injection`, `header_injection`, `open_redirect`, `ssti`, `xxe`, `prototype_pollution`, `all`. Full schema: [Configuration](https://nyxscan.dev/docs/configuration.html). Run `nyx rules list` to browse the registry from the terminal.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
Under active development. APIs, detector behavior, and configuration options may change between releases. Rule-level F1 on the 507-case corpus is the CI regression floor; per-language detail lives in [`tests/benchmark/RESULTS.md`](tests/benchmark/RESULTS.md).
|
||||
Under active development. APIs, detector behavior, and configuration options may change between releases. Rule-level F1 on the synthetic corpus is the CI regression floor; per-language detail lives in [`tests/benchmark/RESULTS.md`](tests/benchmark/RESULTS.md).
|
||||
|
||||
Taint analysis is interprocedural. Persisted per-function SSA summaries carry per-return-path transforms and parameter-granularity points-to, and call-graph SCCs (including SCCs that span files) iterate to a joint fixed-point. The default `balanced` profile also runs k=1 context-sensitive inlining for intra-file callees. Symex (with cross-file and interprocedural frames) and the demand-driven backwards walk are opt-in. Enable them individually with `--symex` and `--backwards-analysis`, or together with `--engine-profile deep`.
|
||||
|
||||
|
|
@ -234,12 +267,12 @@ Limitations:
|
|||
|
||||
## Documentation
|
||||
|
||||
Browse the full docs site at **[elicpeter.github.io/nyx](https://elicpeter.github.io/nyx/)**.
|
||||
Browse the full docs site at **[nyxscan.dev/docs](https://nyxscan.dev/docs/)**.
|
||||
|
||||
- [Quick Start](https://elicpeter.github.io/nyx/quickstart.html) · [CLI Reference](https://elicpeter.github.io/nyx/cli.html) · [Installation](https://elicpeter.github.io/nyx/installation.html)
|
||||
- [`nyx serve`](https://elicpeter.github.io/nyx/serve.html) · [Output Formats](https://elicpeter.github.io/nyx/output.html) · [Configuration](https://elicpeter.github.io/nyx/configuration.html)
|
||||
- [How it works](https://elicpeter.github.io/nyx/how-it-works.html) · [Detectors](https://elicpeter.github.io/nyx/detectors.html) ([Taint](https://elicpeter.github.io/nyx/detectors/taint.html), [CFG](https://elicpeter.github.io/nyx/detectors/cfg.html), [State](https://elicpeter.github.io/nyx/detectors/state.html), [AST Patterns](https://elicpeter.github.io/nyx/detectors/patterns.html))
|
||||
- [Rule Reference](https://elicpeter.github.io/nyx/rules.html) · [Language Maturity](https://elicpeter.github.io/nyx/language-maturity.html) · [Advanced Analysis](https://elicpeter.github.io/nyx/advanced-analysis.html) · [Auth Analysis](https://elicpeter.github.io/nyx/auth.html)
|
||||
- [Quick Start](https://nyxscan.dev/docs/quickstart.html) · [CLI Reference](https://nyxscan.dev/docs/cli.html) · [Installation](https://nyxscan.dev/docs/installation.html)
|
||||
- [`nyx serve`](https://nyxscan.dev/docs/serve.html) · [Output Formats](https://nyxscan.dev/docs/output.html) · [Configuration](https://nyxscan.dev/docs/configuration.html) · [Dynamic verification](https://nyxscan.dev/docs/dynamic.html)
|
||||
- [How it works](https://nyxscan.dev/docs/how-it-works.html) · [Detectors](https://nyxscan.dev/docs/detectors.html) ([Taint](https://nyxscan.dev/docs/detectors/taint.html), [CFG](https://nyxscan.dev/docs/detectors/cfg.html), [State](https://nyxscan.dev/docs/detectors/state.html), [AST Patterns](https://nyxscan.dev/docs/detectors/patterns.html))
|
||||
- [Rule Reference](https://nyxscan.dev/docs/rules.html) · [Language Maturity](https://nyxscan.dev/docs/language-maturity.html) · [Advanced Analysis](https://nyxscan.dev/docs/advanced-analysis.html) · [Auth Analysis](https://nyxscan.dev/docs/auth.html)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<div align="center">
|
||||
<img src="assets/nyx-wordmark.svg" alt="nyx" height="110"/>
|
||||
<img src="assets/nyx-readme-header.png" alt="NYX" width="640"/>
|
||||
|
||||
**本地优先的安全扫描器,自带浏览器 UI。在本地扫描代码仓库并在浏览器中分诊处理,无需云端、无需账号。**
|
||||
**本地优先的安全扫描器,带沙箱动态验证和浏览器 UI。在本地扫描代码仓库并在浏览器中分诊处理,无需云端、无需账号。**
|
||||
|
||||
[](https://crates.io/crates/nyx-scanner)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://github.com/elicpeter/nyx/actions)
|
||||
[](https://elicpeter.github.io/nyx/)
|
||||
[](https://nyxscan.dev/docs/)
|
||||
|
||||
[English](./README.md) · 简体中文
|
||||
</div>
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
## 本地扫描,本地浏览
|
||||
|
||||
Nyx 在你的代码仓库上运行跨语言污点分析,然后将结果通过绑定到 `127.0.0.1` 的 React UI 提供给你。你会得到一份带严重等级、证据、以及分步**流可视化**的发现列表,从源 → 净化器 → 汇逐步呈现数据流。分诊决策持久化在 `.nyx/triage.json` 中,与代码一同提交,团队共享同一份分诊状态。
|
||||
Nyx 在你的代码仓库上运行跨语言污点分析,然后对中高置信度发现运行小型沙箱 harness,验证真实代码里 source 到 sink 的流是否会触发。结果通过绑定到 `127.0.0.1` 的 React UI 提供给你。你会看到严重等级、静态证据、动态验证结果,以及分步**流可视化**,从源 → 净化器 → 汇逐步呈现数据流。分诊决策持久化在 `.nyx/triage.json` 中,与代码一同提交,团队共享同一份分诊状态。
|
||||
|
||||
```bash
|
||||
cargo install nyx-scanner
|
||||
|
|
@ -26,7 +26,7 @@ nyx scan # 运行分析器,把发现缓存到 .nyx/
|
|||
nyx serve # 在浏览器中打开 http://localhost:9700
|
||||
```
|
||||
|
||||
一切都留在你本地:仅回环绑定、强制 host 头校验、所有变更操作均带 CSRF、无遥测、无登录。
|
||||
一切都留在你本地:仅回环绑定、强制 host 头校验、所有变更操作均带 CSRF、无远程遥测、无登录。
|
||||
|
||||
<p align="center"><img src="assets/screenshots/overview.png" alt="一个小型 JS 应用的总览仪表盘:健康分 C 78,五项分量分解(严重度压力、置信度质量、趋势、分诊覆盖、回归抗性),3 条发现,OWASP A03 与 A02 类别,置信度分布与问题类别条形图,受影响最多的文件" width="900"/></p>
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ nyx serve # 在浏览器中打开 http://localhost:9700
|
|||
|---|---|
|
||||
| **总览** | 仪表盘:按严重等级分类的发现计数、热点文件、引擎画像摘要 |
|
||||
| **发现** | 可浏览列表,含严重度徽章、分诊状态、规则筛选、语言筛选 |
|
||||
| **发现详情** | 流路径可视化,带编号步骤(源 → 净化器 → 汇)、代码片段、证据、跨文件标记、分诊下拉框 |
|
||||
| **发现详情** | 流路径可视化,带编号步骤(源 → 净化器 → 汇)、动态验证结果、代码片段、证据、跨文件标记、分诊下拉框 |
|
||||
| **分诊** | 批量更新状态(open、investigating、fixed、false_positive、accepted_risk、suppressed),审计日志,JSON 导入/导出 |
|
||||
| **资源管理器** | 文件树,含每个文件的符号列表与发现叠加层 |
|
||||
| **扫描** | 历史记录、指标,对比两次扫描查看差异 |
|
||||
|
|
@ -46,7 +46,7 @@ nyx serve # 在浏览器中打开 http://localhost:9700
|
|||
| **配置** | 实时配置编辑器;无需重启即可重载 |
|
||||
|
||||
|
||||
`nyx serve` 参数:`--port <N>`(默认 `9700`)、`--host <addr>`(仅回环:`127.0.0.1`、`localhost`、`::1`)、`--no-browser`。持久化设置见 `nyx.conf` 的 `[server]` 段,分页面 UI 介绍与安全模型详见 [Browser UI 指南](https://elicpeter.github.io/nyx/serve.html)。
|
||||
`nyx serve` 参数:`--port <N>`(默认 `9700`)、`--host <addr>`(仅回环:`127.0.0.1`、`localhost`、`::1`)、`--no-browser`。持久化设置见 `nyx.conf` 的 `[server]` 段,分页面 UI 介绍与安全模型详见 [Browser UI 指南](https://nyxscan.dev/docs/serve.html)。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -71,12 +71,12 @@ nyx scan --mode ast
|
|||
nyx scan --engine-profile deep
|
||||
```
|
||||
|
||||
正向跨文件污点在所有画像下都会运行。Symex 与按需后向遍历是可选项,可通过 `--engine-profile deep` 一次性开启,或单独开启(`--symex`、`--backwards-analysis`)。完整开关矩阵见 [CLI 参考](https://elicpeter.github.io/nyx/cli.html#engine-depth-profile)。
|
||||
正向跨文件污点在所有画像下都会运行。Symex 与按需后向遍历是可选项,可通过 `--engine-profile deep` 一次性开启,或单独开启(`--symex`、`--backwards-analysis`)。完整开关矩阵见 [CLI 参考](https://nyxscan.dev/docs/cli.html#engine-depth-profile)。
|
||||
|
||||
### GitHub Action
|
||||
|
||||
```yaml
|
||||
- uses: elicpeter/nyx@v0.7.0
|
||||
- uses: elicpeter/nyx@v0.8.0
|
||||
with:
|
||||
format: sarif
|
||||
fail-on: MEDIUM
|
||||
|
|
@ -117,7 +117,7 @@ cd nyx && cargo build --release
|
|||
|
||||
## 语言支持
|
||||
|
||||
全部 10 种语言都通过 tree-sitter 解析并跑完整流水线,但规则深度与引擎覆盖并不均衡。在 [`tests/benchmark/ground_truth.json`](tests/benchmark/ground_truth.json) 的 507 案例语料上,所有十种语言的基准 F1 均为 100%,因此 F1 已无法单独区分梯度。分级反映规则深度、门控汇覆盖、以及合成语料未充分覆盖的结构性惯用法:
|
||||
全部 10 种语言都通过 tree-sitter 解析并跑完整流水线,但规则深度与引擎覆盖并不均衡。在 [`tests/benchmark/ground_truth.json`](tests/benchmark/ground_truth.json) 的合成语料上,所有十种语言在最近一次基线测量中 F1 均为 100%(见 [`tests/benchmark/RESULTS.md`](tests/benchmark/RESULTS.md)),因此 F1 已无法单独区分梯度。分级反映规则深度、门控汇覆盖、以及合成语料未充分覆盖的结构性惯用法:
|
||||
|
||||
| 梯度 | 语言 | F1 | 适合用作 CI 门禁吗? |
|
||||
|---|---|---|---|
|
||||
|
|
@ -125,7 +125,7 @@ cd nyx && cargo build --release
|
|||
| **Beta** | Java、PHP、Ruby、Rust、Go | 100% | 适合,需轻度 FP 分诊 |
|
||||
| **预览** | C、C++ | 合成语料 100% | 不适合。已跟踪 STL 容器流、builder 链、内联类成员函数;尚未覆盖深度指针别名与函数指针。建议与 clang-tidy 或 Clang Static Analyzer 搭配使用 |
|
||||
|
||||
聚合规则级 F1:100.0%(P=1.000,R=1.000)。所有真实 CVE 用例均触发,语料无未关闭的 FP。各维度详情与已知盲区见 [语言成熟度页面](https://elicpeter.github.io/nyx/language-maturity.html)。
|
||||
所有真实 CVE 用例均触发,语料在记录基线下无未关闭的 FP(P=R=F1=1.000)。各维度详情与已知盲区见 [语言成熟度页面](https://nyxscan.dev/docs/language-maturity.html)。
|
||||
|
||||
### 通过真实 CVE 验证
|
||||
|
||||
|
|
@ -180,9 +180,22 @@ cd nyx && cargo build --release
|
|||
1. **Pass 1**:用 tree-sitter 解析每个文件,构建过程内 CFG(petgraph),下降到剪枝后的 SSA(在支配边界上做 Cytron phi 插入),并导出每函数摘要(source/sanitizer/sink 能力位、污点变换、指向集、被调集合)。
|
||||
2. **摘要合并**:将每文件摘要并集合并为 `GlobalSummaries` 映射。
|
||||
3. **Pass 2**:在跨文件上下文与有限上下文敏感(文件内被调用 k=1 内联,SCC 不动点上限 64 次迭代,超过内联体大小阈值的被调用走摘要回退)下重新分析每个文件。正向数据流工作表通过 SSA 格传播污点,保证收敛。调用图 SCC 迭代到不动点(在上限内),使相互递归函数能拿到准确摘要。
|
||||
4. **排序、去重、输出**:按 严重度 × 证据强度 × 源类可利用性 打分,并输出到控制台、JSON 或 SARIF。
|
||||
4. **排序、去重、动态验证、输出**:按 严重度 × 证据强度 × 源类可利用性 打分。默认构建会对中高置信度发现做动态验证,然后输出到控制台、JSON、SARIF 和浏览器 UI。
|
||||
|
||||
检测器家族:污点(跨文件 source→sink,含 SQLi、XSS、命令/代码执行、反序列化、SSRF、路径穿越、格式串、加密、LDAP 注入、XPath 注入、HTTP 头/响应拆分、开放重定向、服务端模板注入、XXE、原型污染、数据外泄、以及 auth 折入的能力位类规则)、CFG 结构(鉴权缺失、未守卫汇、资源泄漏)、状态模型(use-after-close、double-close、must-leak、unauthed-access)、AST 模式(tree-sitter 结构匹配)。完整检测器文档:[Detectors](https://elicpeter.github.io/nyx/detectors.html)。
|
||||
检测器家族:污点(跨文件 source→sink,含 SQLi、XSS、命令/代码执行、反序列化、SSRF、路径穿越、格式串、加密、LDAP 注入、XPath 注入、HTTP 头/响应拆分、开放重定向、服务端模板注入、XXE、原型污染、数据外泄、以及 auth 折入的能力位类规则)、CFG 结构(鉴权缺失、未守卫汇、资源泄漏)、状态模型(use-after-close、double-close、must-leak、unauthed-access)、AST 模式(tree-sitter 结构匹配)。完整检测器文档:[Detectors](https://nyxscan.dev/docs/detectors.html)。
|
||||
|
||||
---
|
||||
|
||||
## 动态验证
|
||||
|
||||
静态分析说明 source 到 sink 可达。动态验证会尝试证明这条路径在真实代码里会触发。默认构建开启该功能,`nyx scan` 会为中高置信度发现生成 harness,在沙箱中用 curated payload 运行,并把结果写入 `evidence.dynamic_verdict`。
|
||||
|
||||
```bash
|
||||
nyx scan --verify # 默认行为的显式写法
|
||||
nyx scan --no-verify # 只跑静态分析,适合本地快速循环
|
||||
```
|
||||
|
||||
`Confirmed` 只有在攻击 payload 触发 sink 且对应的良性 control 保持干净时才会出现。`NotConfirmed` 表示 harness 跑完但没有触发,不等于发现已关闭。完整能力矩阵、后端与限制见 [Dynamic verification](https://nyxscan.dev/docs/dynamic.html)。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -207,13 +220,13 @@ kind = "sanitizer"
|
|||
cap = "html_escape"
|
||||
```
|
||||
|
||||
或交互式添加规则:`nyx config add-rule --lang javascript --matcher escapeHtml --kind sanitizer --cap html_escape`。能力位(caps):`env_var`、`html_escape`、`shell_escape`、`url_encode`、`json_parse`、`file_io`、`fmt_string`、`sql_query`、`deserialize`、`ssrf`、`data_exfil`、`code_exec`、`crypto`、`unauthorized_id`、`ldap_injection`、`xpath_injection`、`header_injection`、`open_redirect`、`ssti`、`xxe`、`prototype_pollution`、`all`。完整 schema:[Configuration](https://elicpeter.github.io/nyx/configuration.html)。运行 `nyx rules list` 可在终端浏览注册表。
|
||||
或交互式添加规则:`nyx config add-rule --lang javascript --matcher escapeHtml --kind sanitizer --cap html_escape`。能力位(caps):`env_var`、`html_escape`、`shell_escape`、`url_encode`、`json_parse`、`file_io`、`fmt_string`、`sql_query`、`deserialize`、`ssrf`、`data_exfil`、`code_exec`、`crypto`、`unauthorized_id`、`ldap_injection`、`xpath_injection`、`header_injection`、`open_redirect`、`ssti`、`xxe`、`prototype_pollution`、`all`。完整 schema:[Configuration](https://nyxscan.dev/docs/configuration.html)。运行 `nyx rules list` 可在终端浏览注册表。
|
||||
|
||||
---
|
||||
|
||||
## 状态
|
||||
|
||||
正在积极开发中。API、检测器行为、配置项可能在版本间发生变化。507 案例语料上的规则级 F1 是 CI 回归下限;分语言详情见 [`tests/benchmark/RESULTS.md`](tests/benchmark/RESULTS.md)。
|
||||
正在积极开发中。API、检测器行为、配置项可能在版本间发生变化。合成语料上的规则级 F1 是 CI 回归下限;分语言详情见 [`tests/benchmark/RESULTS.md`](tests/benchmark/RESULTS.md)。
|
||||
|
||||
污点分析是过程间的。持久化的每函数 SSA 摘要带有按返回路径的变换与参数粒度的指向集,调用图 SCC(包括跨文件 SCC)迭代到联合不动点。默认 `balanced` 画像还会对文件内被调用做 k=1 上下文敏感内联。Symex(含跨文件与过程间帧)以及按需后向遍历是可选项。可分别用 `--symex` 与 `--backwards-analysis` 单独开启,或通过 `--engine-profile deep` 一并开启。
|
||||
|
||||
|
|
@ -228,12 +241,12 @@ cap = "html_escape"
|
|||
|
||||
## 文档
|
||||
|
||||
完整文档站点:**[elicpeter.github.io/nyx](https://elicpeter.github.io/nyx/)**。
|
||||
完整文档站点:**[nyxscan.dev/docs](https://nyxscan.dev/docs/)**。
|
||||
|
||||
- [Quick Start](https://elicpeter.github.io/nyx/quickstart.html) · [CLI Reference](https://elicpeter.github.io/nyx/cli.html) · [Installation](https://elicpeter.github.io/nyx/installation.html)
|
||||
- [`nyx serve`](https://elicpeter.github.io/nyx/serve.html) · [Output Formats](https://elicpeter.github.io/nyx/output.html) · [Configuration](https://elicpeter.github.io/nyx/configuration.html)
|
||||
- [How it works](https://elicpeter.github.io/nyx/how-it-works.html) · [Detectors](https://elicpeter.github.io/nyx/detectors.html)([Taint](https://elicpeter.github.io/nyx/detectors/taint.html)、[CFG](https://elicpeter.github.io/nyx/detectors/cfg.html)、[State](https://elicpeter.github.io/nyx/detectors/state.html)、[AST Patterns](https://elicpeter.github.io/nyx/detectors/patterns.html))
|
||||
- [Rule Reference](https://elicpeter.github.io/nyx/rules.html) · [Language Maturity](https://elicpeter.github.io/nyx/language-maturity.html) · [Advanced Analysis](https://elicpeter.github.io/nyx/advanced-analysis.html) · [Auth Analysis](https://elicpeter.github.io/nyx/auth.html)
|
||||
- [Quick Start](https://nyxscan.dev/docs/quickstart.html) · [CLI Reference](https://nyxscan.dev/docs/cli.html) · [Installation](https://nyxscan.dev/docs/installation.html)
|
||||
- [`nyx serve`](https://nyxscan.dev/docs/serve.html) · [Output Formats](https://nyxscan.dev/docs/output.html) · [Configuration](https://nyxscan.dev/docs/configuration.html)
|
||||
- [How it works](https://nyxscan.dev/docs/how-it-works.html) · [Detectors](https://nyxscan.dev/docs/detectors.html)([Taint](https://nyxscan.dev/docs/detectors/taint.html)、[CFG](https://nyxscan.dev/docs/detectors/cfg.html)、[State](https://nyxscan.dev/docs/detectors/state.html)、[AST Patterns](https://nyxscan.dev/docs/detectors/patterns.html))
|
||||
- [Rule Reference](https://nyxscan.dev/docs/rules.html) · [Language Maturity](https://nyxscan.dev/docs/language-maturity.html) · [Advanced Analysis](https://nyxscan.dev/docs/advanced-analysis.html) · [Auth Analysis](https://nyxscan.dev/docs/auth.html)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
94
RELEASE_CHECKLIST.md
Normal file
94
RELEASE_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Release checklist: 0.8.0 (dynamic verification)
|
||||
|
||||
Maintainer-facing gate for cutting `0.8.0`. The release ships the dynamic
|
||||
verifier (Tracks J through S of `.pitboss/play/plan.md`). Sign-off requires
|
||||
every row below green, and every CI matrix row green for at least three
|
||||
consecutive runs on `master`.
|
||||
|
||||
Legend: `[x]` verified locally on the dev reference machine, `[ ]` confirmed
|
||||
by CI (must hold for three consecutive runs before tagging).
|
||||
|
||||
## Cross-cutting invariants
|
||||
|
||||
- [x] `cargo check --no-default-features --features serve` green.
|
||||
- [x] `cargo check --features dynamic` green.
|
||||
- [x] `cargo nextest run --features dynamic` green: 6545 passed, 0 failed, 16 skipped.
|
||||
- [x] Determinism: every payload RNG seeds from `spec.spec_hash`; oracle canaries derive from `BLAKE3(spec_hash || run_nonce)`. `scripts/check_no_unseeded_rand.sh` audits the tree.
|
||||
- [x] Observability: each new code path emits a `VerifyTrace` event and a typed `Inconclusive` / `Unsupported` reason.
|
||||
- [x] Security: every sink-under-test routes through `src/dynamic/policy.rs` deny rules; no phase weakened the seccomp / `.sb` profile sets.
|
||||
- [ ] Performance: default `nyx scan` (no `--verify`) latency does not regress.
|
||||
|
||||
## Ship gates (`scripts/m7_ship_gate.sh`)
|
||||
|
||||
- [x] Gate 1: static-only scan green on `tests/benchmark/corpus`.
|
||||
- [x] Gate 2: `cargo nextest run --features dynamic` green (covers Gate 4 + Gate 5 binaries).
|
||||
- [x] Gate 3: with-verify / static-only wall-clock ratio <= 1.5x on `benches/fixtures/`.
|
||||
- [x] Gate 4: SARIF schema validation on every dynamic verdict variant.
|
||||
- [x] Gate 5: layering boundary test green.
|
||||
- [ ] Gate 6: Java OWASP Benchmark v1.2 `--verify` acceptance (wall-clock <= 15 min CI, per-cap precision >= 0.85 / recall >= 0.40, per-`(cap, lang)` budget). Self-skips without `NYX_OWASP_CORPUS`.
|
||||
- [ ] Gate 7: NodeGoat + Juice Shop acceptance. Self-skips without `NYX_NODEGOAT_CORPUS` / `NYX_JUICESHOP_CORPUS`.
|
||||
- [ ] Gate 8: RailsGoat / DVWA / DVPWA / gosec / RustSec acceptance. Self-skips without the matching `NYX_*_CORPUS`.
|
||||
|
||||
Gates 6 through 8 run against real corpora that are not vendored into the repo.
|
||||
They are enforced in the `eval` workflow with the corpora cached on the CI
|
||||
runner. Locally they self-skip with a clear message.
|
||||
|
||||
## CI matrix rows (must be green three runs running)
|
||||
|
||||
`ci.yml`:
|
||||
- [ ] frontend, rustfmt, clippy-stable, cargo-deny, unused-deps, third-party-licenses
|
||||
- [ ] docs-fresh (`nyx-docgen` output committed), rustdoc
|
||||
- [ ] rust-beta-build, msrv
|
||||
- [ ] rust-stable-test-linux-without-docker, rust-stable-test-linux-with-docker (`cargo nextest run --all-features`)
|
||||
|
||||
`dynamic.yml` (each runs `cargo nextest run --features dynamic`):
|
||||
- [ ] linux-process-only
|
||||
- [ ] linux-with-docker
|
||||
- [ ] macos
|
||||
|
||||
`eval.yml`:
|
||||
- [ ] owasp (Gate 6)
|
||||
- [ ] jsts matrix: nodegoat, juiceshop (Gate 7)
|
||||
- [ ] polyglot matrix: railsgoat, dvwa, dvpwa, gosec, rustsec (Gate 8)
|
||||
|
||||
## Docs and metadata
|
||||
|
||||
- [x] `Cargo.toml` version bumped to `0.8.0`; `Cargo.lock` regenerated.
|
||||
- [x] `docs/dynamic.md` rewritten: cap x lang matrix, framework adapter table, oracle table, performance budgets, limitations.
|
||||
- [x] `README.md` dynamic verification section + docs link.
|
||||
- [x] `CHANGELOG.md` `[0.8.0]` entry covers Tracks J through S.
|
||||
- [x] Stray version strings updated (README GitHub Action pin, telemetry doc example).
|
||||
|
||||
## Known limitations carried into 0.8.0
|
||||
|
||||
These are documented in `docs/dynamic.md` and accepted for the MVP. They are
|
||||
not release blockers, but the release notes should not overstate the verifier.
|
||||
|
||||
- **Guarded-sink over-confirmation (resolved on `dynamic`).** The synthesized
|
||||
harness now drives the finding's enclosing entry function when one is
|
||||
derivable, routing the payload to the tainted parameter, so a guard that
|
||||
lives in the caller (a `Object.create(null)` merge target, an allowlisting
|
||||
`resolveClass`, a const-name check before `Marshal.load`) runs first and
|
||||
participates in the verdict. The build-time entry-vs-sink choice is recorded
|
||||
on the verify trace as `entry_invocation`. When no enclosing entry can be
|
||||
derived the harness falls back to driving the sink directly, which can still
|
||||
over-confirm a guard it never executes. On the in-house fixture set the
|
||||
verify scan now confirms the 8 genuine vulnerabilities and reads
|
||||
`NotConfirmed` on all 4 negative-control files.
|
||||
- **In-house confirmed rate is modest.** A `--verify` scan of
|
||||
`tests/dynamic_fixtures` (process backend) lands 8 Confirmed / 15
|
||||
NotConfirmed / 115 Inconclusive / 137 Unsupported of 275. The Unsupported
|
||||
bulk is `SoundOracleUnavailable` (ENV_VAR / SHELL_ESCAPE / URL_ENCODE source
|
||||
and sanitizer caps, correct by design); the Inconclusive bulk is
|
||||
`SpecDerivationFailed` on benign and scaffolding fixtures with no derivable
|
||||
flow. The authoritative confirmed / precision / recall numbers come from the
|
||||
real-corpus gates (6 through 8), which require the corpora.
|
||||
- **Real-corpus gates unverified locally.** Gates 6 through 8 self-skip without
|
||||
`NYX_*_CORPUS`. The >= 40% confirmed and >= 0.85 precision targets are
|
||||
enforced only in the `eval` workflow.
|
||||
|
||||
## Tag
|
||||
|
||||
- [ ] Three consecutive green CI runs on `master` confirmed.
|
||||
- [ ] Real-corpus gates (6 through 8) green in the `eval` workflow with corpora wired.
|
||||
- [ ] `git tag v0.8.0` and push; `release-build.yml` publishes the binaries and `SHA256SUMS`.
|
||||
|
|
@ -44,8 +44,8 @@
|
|||
|
||||
<h2>Overview of licenses:</h2>
|
||||
<ul class="licenses-overview">
|
||||
<li><a href="#Apache-2.0">Apache License 2.0</a> (156)</li>
|
||||
<li><a href="#MIT">MIT License</a> (70)</li>
|
||||
<li><a href="#Apache-2.0">Apache License 2.0</a> (160)</li>
|
||||
<li><a href="#MIT">MIT License</a> (71)</li>
|
||||
<li><a href="#Zlib">zlib License</a> (2)</li>
|
||||
<li><a href="#BSD-2-Clause">BSD 2-Clause "Simplified" License</a> (1)</li>
|
||||
<li><a href="#BSD-3-Clause">BSD 3-Clause "New" or "Revised" License</a> (1)</li>
|
||||
|
|
@ -2638,6 +2638,7 @@ limitations under the License.</pre>
|
|||
<li><a href=" https://github.com/smol-rs/fastrand ">fastrand 2.4.1</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/cc-rs ">find-msvc-tools 0.1.9</a></li>
|
||||
<li><a href=" https://github.com/petgraph/fixedbitset ">fixedbitset 0.5.7</a></li>
|
||||
<li><a href=" https://github.com/servo/rust-fnv ">fnv 1.0.7</a></li>
|
||||
<li><a href=" https://github.com/servo/rust-url ">form_urlencoded 1.2.2</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/glob ">glob 0.3.3</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/hashbrown ">hashbrown 0.14.5</a></li>
|
||||
|
|
@ -2661,6 +2662,8 @@ limitations under the License.</pre>
|
|||
<li><a href=" https://github.com/servo/rust-url/ ">percent-encoding 2.3.2</a></li>
|
||||
<li><a href=" https://github.com/petgraph/petgraph ">petgraph 0.8.3</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/pkg-config-rs ">pkg-config 0.3.33</a></li>
|
||||
<li><a href=" https://github.com/tokio-rs/prost ">prost-derive 0.14.3</a></li>
|
||||
<li><a href=" https://github.com/tokio-rs/prost ">prost 0.14.3</a></li>
|
||||
<li><a href=" https://github.com/rayon-rs/rayon ">rayon-core 1.13.0</a></li>
|
||||
<li><a href=" https://github.com/rayon-rs/rayon ">rayon 1.12.0</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/regex ">regex-automata 0.4.14</a></li>
|
||||
|
|
@ -4127,6 +4130,7 @@ limitations under the License.
|
|||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/zrzka/anes-rs ">anes 0.1.6</a></li>
|
||||
<li><a href=" https://github.com/dtolnay/anyhow ">anyhow 1.0.102</a></li>
|
||||
<li><a href=" https://github.com/BLAKE3-team/BLAKE3 ">blake3 1.8.5</a></li>
|
||||
<li><a href=" https://github.com/cesarb/constant_time_eq ">constant_time_eq 0.4.2</a></li>
|
||||
<li><a href=" https://github.com/soc/directories-rs ">directories 6.0.0</a></li>
|
||||
|
|
@ -4557,7 +4561,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|||
<h3 id="GPL-3.0">GNU General Public License v3.0 only</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/elicpeter/nyx ">nyx-scanner 0.7.0</a></li>
|
||||
<li><a href=" https://github.com/elicpeter/nyx ">nyx-scanner 0.8.0</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
|
|
@ -4894,6 +4898,39 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
</pre>
|
||||
</li>
|
||||
<li class="license">
|
||||
<h3 id="MIT">MIT License</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/hyperium/h2 ">h2 0.4.14</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">Copyright (c) 2017 h2 authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</pre>
|
||||
</li>
|
||||
<li class="license">
|
||||
|
|
|
|||
BIN
assets/nyx-readme-header.png
Normal file
BIN
assets/nyx-readme-header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
24
assets/nyx-readme-header.svg
Normal file
24
assets/nyx-readme-header.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="275" viewBox="0 0 900 275" role="img" aria-labelledby="title desc">
|
||||
<title id="title">NYX</title>
|
||||
<desc id="desc">NYX security scanner.</desc>
|
||||
<defs>
|
||||
<style>
|
||||
.banner {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(146 48)" xml:space="preserve">
|
||||
<text class="banner" x="0" y="0" fill="#2ea067" xml:space="preserve">███╗ ██╗██╗ ██╗██╗ ██╗</text>
|
||||
<text class="banner" x="0" y="43" fill="#2ea067" xml:space="preserve">████╗ ██║╚██╗ ██╔╝╚██╗██╔╝</text>
|
||||
<text class="banner" x="0" y="86" fill="#2ea067" xml:space="preserve">██╔██╗ ██║ ╚████╔╝ ╚███╔╝</text>
|
||||
<text class="banner" x="0" y="129" fill="#2ea067" xml:space="preserve">██║╚██╗██║ ╚██╔╝ ██╔██╗</text>
|
||||
<text class="banner" x="0" y="172" fill="#2ea067" xml:space="preserve">██║ ╚████║ ██║ ██╔╝ ██╗</text>
|
||||
<text class="banner" x="0" y="215" fill="#2ea067" xml:space="preserve">╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
686
benches/dynamic_bench.rs
Normal file
686
benches/dynamic_bench.rs
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
//! Dynamic verification benchmarks (§8.4).
|
||||
//!
|
||||
//! Tracks the per-scan cost anchors:
|
||||
//!
|
||||
//! 1. `harness_build_cold` — fresh workdir, spec → BuiltHarness (source gen + disk write).
|
||||
//! 2. `harness_build_warm` — same spec, workdir already staged (file write skipped).
|
||||
//! 3. `sandbox_run_payload` — single payload run via process backend against
|
||||
//! sqli_positive.py (subprocess + settrace overhead, no networking).
|
||||
//! 4. `docker_image_build` — cold image pull/build for the python:3-slim base.
|
||||
//! 5. `docker_exec_warm` — `docker exec` into a running container (no cold start).
|
||||
//! 6. `docker_payload_cost` — per-payload sandbox cost via docker backend end-to-end.
|
||||
//! 7. `composite_chain_reverify_dispatch` — `reverify_top_chains` on a
|
||||
//! synthetic 3-member chain with no member diags. Measures the no-derive
|
||||
//! dispatch path (chain_step_specs miss, early-exit build/run loops,
|
||||
//! Inconclusive verdict allocation, severity downgrade).
|
||||
//! 8. `composite_chain_reverify_stub_confirmed` — same chain shape, stubbed
|
||||
//! reverifier returning `Confirmed`. Measures the apply-verdict happy path
|
||||
//! (no severity bucket change).
|
||||
//! 9. `composite_chain_reverify_top_n_slice` — 5-chain slice with `top_n=3`.
|
||||
//! Measures the slice traversal cost so a regression that walks the full
|
||||
//! slice instead of the prefix is visible.
|
||||
//! 10. `composite_chain_reverify_replay_stable` — same chain shape as
|
||||
//! `stub_confirmed`, but with `VerifyOptions::replay_stable_check=true`
|
||||
//! and a stub that stamps `replay_stable=Some(true)`. Anchors the
|
||||
//! apply-verdict allocation cost when the telemetry stability field
|
||||
//! is populated; a regression that adds per-chain work behind the
|
||||
//! replay opt-in (e.g. an extra run_chain_steps call leaking out of
|
||||
//! the live path into the stub layer) shows up here.
|
||||
//!
|
||||
//! Wall-clock budget anchors for the composite reverify path: the live
|
||||
//! process backend stays under 400ms per 3-member chain, the docker
|
||||
//! backend under 1500ms. Those live-run numbers are covered by the
|
||||
//! `flask_eval_chain_reverify_populates_dynamic_verdict` integration
|
||||
//! test in `tests/chain_emission_e2e.rs`; the microbenches here anchor
|
||||
//! the dispatch + verdict-application overhead so regressions on the
|
||||
//! API-shape half land in the criterion baseline.
|
||||
//!
|
||||
//! Baselines committed to `benches/dynamic_bench_baseline.json`.
|
||||
//! Run: `cargo bench --features dynamic -- dynamic`
|
||||
//!
|
||||
//! Docker benchmarks are no-ops when docker is unavailable (skipped, not failed).
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
use nyx_scanner::dynamic::spec::{
|
||||
EntryKind, HarnessSpec, JavaToolchain, PayloadSlot, SpecDerivationStrategy,
|
||||
};
|
||||
#[cfg(feature = "dynamic")]
|
||||
use nyx_scanner::labels::Cap;
|
||||
#[cfg(feature = "dynamic")]
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn make_rust_sqli_spec() -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "bench_rust_0001".into(),
|
||||
entry_file: "tests/dynamic_fixtures/rust/sqli_positive.rs".into(),
|
||||
entry_name: "run".into(),
|
||||
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
|
||||
lang: Lang::Rust,
|
||||
toolchain_id: "rust-stable".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::SQL_QUERY,
|
||||
constraint_hints: vec![],
|
||||
sink_file: "tests/dynamic_fixtures/rust/sqli_positive.rs".into(),
|
||||
sink_line: 18,
|
||||
spec_hash: "benchrustsqli0001".into(),
|
||||
derivation: SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
java_toolchain: JavaToolchain::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn make_sqli_spec() -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "bench0000000001".into(),
|
||||
entry_file: "tests/dynamic_fixtures/python/sqli_positive.py".into(),
|
||||
entry_name: "login".into(),
|
||||
entry_kind: EntryKind::Function,
|
||||
lang: Lang::Python,
|
||||
toolchain_id: "python-3".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::SQL_QUERY,
|
||||
constraint_hints: vec![],
|
||||
sink_file: "tests/dynamic_fixtures/python/sqli_positive.py".into(),
|
||||
sink_line: 7,
|
||||
spec_hash: "benchsqli000001".into(),
|
||||
derivation: SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
java_toolchain: JavaToolchain::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_harness_build_cold(c: &mut Criterion) {
|
||||
use nyx_scanner::dynamic::harness;
|
||||
let spec = make_sqli_spec();
|
||||
c.bench_function("harness_build_cold", |b| {
|
||||
b.iter(|| {
|
||||
let workdir = std::env::temp_dir()
|
||||
.join("nyx-harness")
|
||||
.join(&spec.spec_hash);
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
harness::build(&spec).expect("harness build")
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_harness_build_warm(c: &mut Criterion) {
|
||||
use nyx_scanner::dynamic::harness;
|
||||
let spec = make_sqli_spec();
|
||||
harness::build(&spec).expect("harness pre-stage");
|
||||
c.bench_function("harness_build_warm", |b| {
|
||||
b.iter(|| harness::build(&spec).expect("harness build warm"));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_sandbox_run_payload(c: &mut Criterion) {
|
||||
use nyx_scanner::dynamic::corpus::payloads_for;
|
||||
use nyx_scanner::dynamic::harness;
|
||||
use nyx_scanner::dynamic::sandbox::{self, SandboxOptions};
|
||||
|
||||
let spec = make_sqli_spec();
|
||||
let harness = harness::build(&spec).expect("harness build");
|
||||
let payloads = payloads_for(Cap::SQL_QUERY);
|
||||
let payload = payloads
|
||||
.iter()
|
||||
.find(|p| !p.is_benign)
|
||||
.expect("sqli payload");
|
||||
let opts = SandboxOptions {
|
||||
timeout: std::time::Duration::from_secs(10),
|
||||
..SandboxOptions::default()
|
||||
};
|
||||
|
||||
c.bench_function("sandbox_run_payload", |b| {
|
||||
b.iter(|| sandbox::run(&harness, payload.bytes, &opts).expect("sandbox run"));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn docker_available() -> bool {
|
||||
std::process::Command::new("docker")
|
||||
.arg("info")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cold docker image pull/build.
|
||||
///
|
||||
/// Measures the time to ensure `python:3-slim` is present locally. On a
|
||||
/// warm cache this is just an inspect call (sub-second). On a cold host it
|
||||
/// includes the pull from the registry.
|
||||
///
|
||||
/// Registers a labelled noop measurement when Docker is absent so criterion's
|
||||
/// output is never empty for this slot.
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_docker_image_build(c: &mut Criterion) {
|
||||
if !docker_available() {
|
||||
c.bench_function("docker_image_build_no_docker", |b| b.iter(|| ()));
|
||||
return;
|
||||
}
|
||||
c.bench_function("docker_image_build", |b| {
|
||||
b.iter(|| {
|
||||
// `docker pull` is idempotent and fast when image is already local.
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["pull", "python:3-slim"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Warm `docker exec` reuse benchmark.
|
||||
///
|
||||
/// Starts a single container before the benchmark loop and measures the cost
|
||||
/// of each `docker exec` call (no cold-start amortisation visible here — that
|
||||
/// is visible by comparing this vs `bench_docker_payload_cost`).
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_docker_exec_warm(c: &mut Criterion) {
|
||||
if !docker_available() {
|
||||
eprintln!("bench_docker_exec_warm: docker unavailable, skipping");
|
||||
return;
|
||||
}
|
||||
// Start a long-lived container for the benchmark.
|
||||
let container = "nyx-bench-exec-warm";
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
container,
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges:true",
|
||||
"--network",
|
||||
"none",
|
||||
"python:3-slim",
|
||||
"sleep",
|
||||
"300",
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
|
||||
c.bench_function("docker_exec_warm", |b| {
|
||||
b.iter(|| {
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["exec", container, "python3", "-c", "pass"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
});
|
||||
});
|
||||
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["stop", container])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
/// Per-payload sandbox cost via docker backend end-to-end.
|
||||
///
|
||||
/// Measures the complete path: harness already built + docker backend +
|
||||
/// process the sqli_positive fixture. The first call includes container
|
||||
/// start; subsequent calls show exec-reuse cost.
|
||||
///
|
||||
/// Registers a labelled noop measurement when Docker is absent so criterion's
|
||||
/// output is never empty for this slot.
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_docker_payload_cost(c: &mut Criterion) {
|
||||
if !docker_available() {
|
||||
c.bench_function("docker_payload_cost_no_docker", |b| b.iter(|| ()));
|
||||
return;
|
||||
}
|
||||
use nyx_scanner::dynamic::corpus::payloads_for;
|
||||
use nyx_scanner::dynamic::harness;
|
||||
use nyx_scanner::dynamic::sandbox::{self, SandboxBackend, SandboxOptions};
|
||||
|
||||
let spec = make_sqli_spec();
|
||||
let built = harness::build(&spec).expect("harness build");
|
||||
let payloads = payloads_for(Cap::SQL_QUERY);
|
||||
let payload = payloads
|
||||
.iter()
|
||||
.find(|p| !p.is_benign)
|
||||
.expect("sqli payload");
|
||||
let opts = SandboxOptions {
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
backend: SandboxBackend::Docker,
|
||||
..SandboxOptions::default()
|
||||
};
|
||||
|
||||
c.bench_function("docker_payload_cost", |b| {
|
||||
b.iter(|| {
|
||||
let _ = sandbox::run(&built, payload.bytes, &opts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Rust harness build (source gen + disk write, no compilation).
|
||||
///
|
||||
/// Measures only `harness::build()` — staging files to the workdir.
|
||||
/// The expensive `cargo build --release` step is NOT included here
|
||||
/// (that is the province of an integration benchmark, not this microbench).
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_rust_harness_build_cold(c: &mut Criterion) {
|
||||
use nyx_scanner::dynamic::harness;
|
||||
let spec = make_rust_sqli_spec();
|
||||
c.bench_function("rust_harness_build_cold", |b| {
|
||||
b.iter(|| {
|
||||
let workdir = std::env::temp_dir()
|
||||
.join("nyx-harness")
|
||||
.join(&spec.spec_hash);
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
harness::build(&spec).expect("harness build")
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn make_js_sqli_spec() -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "bench_js_0001".into(),
|
||||
entry_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(),
|
||||
entry_name: "login".into(),
|
||||
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
|
||||
lang: Lang::JavaScript,
|
||||
toolchain_id: "node-20".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::SQL_QUERY,
|
||||
constraint_hints: vec![],
|
||||
sink_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(),
|
||||
sink_line: 8,
|
||||
spec_hash: "benchjssqli000001".into(),
|
||||
derivation: SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
java_toolchain: JavaToolchain::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn make_go_sqli_spec() -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "bench_go_0001".into(),
|
||||
entry_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(),
|
||||
entry_name: "Login".into(),
|
||||
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
|
||||
lang: Lang::Go,
|
||||
toolchain_id: "go-1.21".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::SQL_QUERY,
|
||||
constraint_hints: vec![],
|
||||
sink_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(),
|
||||
sink_line: 12,
|
||||
spec_hash: "benchgosqli000001".into(),
|
||||
derivation: SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
java_toolchain: JavaToolchain::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn make_java_sqli_spec() -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "bench_java_0001".into(),
|
||||
entry_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(),
|
||||
entry_name: "login".into(),
|
||||
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
|
||||
lang: Lang::Java,
|
||||
toolchain_id: "java-21".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::SQL_QUERY,
|
||||
constraint_hints: vec![],
|
||||
sink_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(),
|
||||
sink_line: 9,
|
||||
spec_hash: "benchjavasqli00001".into(),
|
||||
derivation: SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
java_toolchain: JavaToolchain::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn make_php_sqli_spec() -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "bench_php_0001".into(),
|
||||
entry_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(),
|
||||
entry_name: "login".into(),
|
||||
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
|
||||
lang: Lang::Php,
|
||||
toolchain_id: "php-8".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::SQL_QUERY,
|
||||
constraint_hints: vec![],
|
||||
sink_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(),
|
||||
sink_line: 9,
|
||||
spec_hash: "benchphpsqli000001".into(),
|
||||
derivation: SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
java_toolchain: JavaToolchain::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// JS harness build (source gen + disk write).
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_js_harness_build_cold(c: &mut Criterion) {
|
||||
use nyx_scanner::dynamic::harness;
|
||||
let spec = make_js_sqli_spec();
|
||||
c.bench_function("js_harness_build_cold", |b| {
|
||||
b.iter(|| {
|
||||
let workdir = std::env::temp_dir()
|
||||
.join("nyx-harness")
|
||||
.join(&spec.spec_hash);
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
harness::build(&spec).expect("JS harness build")
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Go harness build (source gen + disk write, no compilation).
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_go_harness_build_cold(c: &mut Criterion) {
|
||||
use nyx_scanner::dynamic::harness;
|
||||
let spec = make_go_sqli_spec();
|
||||
c.bench_function("go_harness_build_cold", |b| {
|
||||
b.iter(|| {
|
||||
let workdir = std::env::temp_dir()
|
||||
.join("nyx-harness")
|
||||
.join(&spec.spec_hash);
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
harness::build(&spec).expect("Go harness build")
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Java harness build (source gen + disk write, no compilation).
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_java_harness_build_cold(c: &mut Criterion) {
|
||||
use nyx_scanner::dynamic::harness;
|
||||
let spec = make_java_sqli_spec();
|
||||
c.bench_function("java_harness_build_cold", |b| {
|
||||
b.iter(|| {
|
||||
let workdir = std::env::temp_dir()
|
||||
.join("nyx-harness")
|
||||
.join(&spec.spec_hash);
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
harness::build(&spec).expect("Java harness build")
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// PHP harness build (source gen + disk write).
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_php_harness_build_cold(c: &mut Criterion) {
|
||||
use nyx_scanner::dynamic::harness;
|
||||
let spec = make_php_sqli_spec();
|
||||
c.bench_function("php_harness_build_cold", |b| {
|
||||
b.iter(|| {
|
||||
let workdir = std::env::temp_dir()
|
||||
.join("nyx-harness")
|
||||
.join(&spec.spec_hash);
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
harness::build(&spec).expect("PHP harness build")
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn mk_chain_member(hash: u64, idx: usize) -> nyx_scanner::chain::FindingRef {
|
||||
use nyx_scanner::surface::SourceLocation;
|
||||
nyx_scanner::chain::FindingRef {
|
||||
finding_id: format!("bench-chain-member-{idx}"),
|
||||
stable_hash: hash,
|
||||
location: SourceLocation::new("bench/synthetic.py", (idx as u32) + 1, 1),
|
||||
rule_id: "taint-unsanitised-flow".into(),
|
||||
cap_bits: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn mk_synthetic_chain(hash: u64, members: usize) -> nyx_scanner::chain::ChainFinding {
|
||||
use nyx_scanner::chain::{ChainFinding, ChainSeverity, ChainSink, ImpactCategory};
|
||||
ChainFinding {
|
||||
stable_hash: hash,
|
||||
members: (0..members)
|
||||
.map(|i| mk_chain_member(hash.wrapping_add(i as u64 + 1), i))
|
||||
.collect(),
|
||||
sink: ChainSink {
|
||||
file: "bench/synthetic.py".into(),
|
||||
line: 99,
|
||||
col: 1,
|
||||
function_name: "sink".into(),
|
||||
cap_bits: 0,
|
||||
},
|
||||
implied_impact: ImpactCategory::Rce,
|
||||
severity: ChainSeverity::Critical,
|
||||
score: 100.0,
|
||||
dynamic_verdict: None,
|
||||
reverify_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
struct BenchConfirmedReverifier;
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
impl nyx_scanner::chain::CompositeReverifier for BenchConfirmedReverifier {
|
||||
fn reverify(
|
||||
&self,
|
||||
_chain: &nyx_scanner::chain::ChainFinding,
|
||||
_member_diags: &[nyx_scanner::commands::scan::Diag],
|
||||
_surface: &nyx_scanner::surface::SurfaceMap,
|
||||
opts: &nyx_scanner::dynamic::verify::VerifyOptions,
|
||||
) -> nyx_scanner::evidence::VerifyResult {
|
||||
// Mirror `DefaultCompositeReverifier::reverify`'s replay-stable
|
||||
// stamping shape so the apply-verdict allocation cost matches
|
||||
// the live path when the opt-in is on. The stub does not
|
||||
// re-run any work (it has none to re-run) but the resulting
|
||||
// `VerifyResult` populates `replay_stable=Some(true)` so
|
||||
// downstream sites that branch on the field exercise the same
|
||||
// path they would for a real Confirmed-with-stable run.
|
||||
let replay_stable = if opts.replay_stable_check {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
nyx_scanner::evidence::VerifyResult {
|
||||
finding_id: "bench".into(),
|
||||
status: nyx_scanner::evidence::VerifyStatus::Confirmed,
|
||||
triggered_payload: None,
|
||||
reason: None,
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 26 dispatch-cost anchor: synthetic 3-member chain with no
|
||||
/// matching member diags. The reverifier walks chain_step_specs (3
|
||||
/// HashMap misses → 3 NoFlowSteps errors), the build loop sees zero
|
||||
/// derived specs and exits early, the run loop sees zero built steps
|
||||
/// and exits early. The composed VerifyResult is allocated and applied
|
||||
/// via `apply_dynamic_verdict` (Inconclusive → severity downgrade).
|
||||
///
|
||||
/// This is the no-toolchain-dep dispatch overhead — a regression here
|
||||
/// signals a hot-path allocation introduced into the reverify pipeline.
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_composite_chain_reverify_dispatch(c: &mut Criterion) {
|
||||
use nyx_scanner::chain::reverify;
|
||||
use nyx_scanner::dynamic::verify::VerifyOptions;
|
||||
use nyx_scanner::surface::SurfaceMap;
|
||||
|
||||
let surface = SurfaceMap::new();
|
||||
let opts = VerifyOptions::default();
|
||||
|
||||
c.bench_function("composite_chain_reverify_dispatch", |b| {
|
||||
b.iter(|| {
|
||||
let mut chains = [mk_synthetic_chain(0xC1A1, 3)];
|
||||
let _ = reverify::reverify_top_chains(&mut chains, &[], &surface, &opts, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Phase 26 stub-reverifier happy-path anchor: synthetic 3-member
|
||||
/// chain driven through `reverify_top_chains_with` + a stubbed
|
||||
/// reverifier returning `Confirmed`. Measures the apply-verdict path
|
||||
/// when the verdict does NOT trigger a severity downgrade, so the
|
||||
/// `ChainReverifyResult` allocation + `chain.apply_dynamic_verdict`
|
||||
/// transition cost is exercised independent of the verdict-side
|
||||
/// allocation in the dispatch bench.
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_composite_chain_reverify_stub_confirmed(c: &mut Criterion) {
|
||||
use nyx_scanner::chain::reverify;
|
||||
use nyx_scanner::dynamic::verify::VerifyOptions;
|
||||
use nyx_scanner::surface::SurfaceMap;
|
||||
|
||||
let surface = SurfaceMap::new();
|
||||
let opts = VerifyOptions::default();
|
||||
let reverifier = BenchConfirmedReverifier;
|
||||
|
||||
c.bench_function("composite_chain_reverify_stub_confirmed", |b| {
|
||||
b.iter(|| {
|
||||
let mut chains = [mk_synthetic_chain(0xC2A2, 3)];
|
||||
let _ = reverify::reverify_top_chains_with(
|
||||
&mut chains,
|
||||
&[],
|
||||
&surface,
|
||||
&opts,
|
||||
1,
|
||||
&reverifier,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Phase 26 top-N slice anchor: 5-chain slice with `top_n=3`. Asserts
|
||||
/// (by way of regression) that the reverify pass never walks past the
|
||||
/// top-N prefix. The fan-in is the per-chain dispatch cost times three;
|
||||
/// a regression that drops the `bound = top_n.min(chains.len())` cap
|
||||
/// would show up as a ~5/3 increase in this bench.
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_composite_chain_reverify_top_n_slice(c: &mut Criterion) {
|
||||
use nyx_scanner::chain::reverify;
|
||||
use nyx_scanner::dynamic::verify::VerifyOptions;
|
||||
use nyx_scanner::surface::SurfaceMap;
|
||||
|
||||
let surface = SurfaceMap::new();
|
||||
let opts = VerifyOptions::default();
|
||||
let reverifier = BenchConfirmedReverifier;
|
||||
|
||||
c.bench_function("composite_chain_reverify_top_n_slice", |b| {
|
||||
b.iter(|| {
|
||||
let mut chains: [nyx_scanner::chain::ChainFinding; 5] = [
|
||||
mk_synthetic_chain(0xC301, 3),
|
||||
mk_synthetic_chain(0xC302, 3),
|
||||
mk_synthetic_chain(0xC303, 3),
|
||||
mk_synthetic_chain(0xC304, 3),
|
||||
mk_synthetic_chain(0xC305, 3),
|
||||
];
|
||||
let _ = reverify::reverify_top_chains_with(
|
||||
&mut chains,
|
||||
&[],
|
||||
&surface,
|
||||
&opts,
|
||||
3,
|
||||
&reverifier,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Phase 26 replay-stable anchor: same 3-member synthetic chain as
|
||||
/// `stub_confirmed`, driven through `reverify_top_chains_with` with
|
||||
/// `VerifyOptions::replay_stable_check=true`. The `BenchConfirmedReverifier`
|
||||
/// stub honours the opt-in by stamping `replay_stable=Some(true)` on
|
||||
/// the returned `VerifyResult`, exercising the apply-verdict path with
|
||||
/// the telemetry stability field populated.
|
||||
///
|
||||
/// Purpose: anchor the cost of the replay-stable apply path so a
|
||||
/// regression that leaks a real `run_chain_steps` invocation into the
|
||||
/// stubbed verifier layer (or that allocates extra state behind the
|
||||
/// `replay_stable_check` toggle in `chain::reverify::apply_one`) shows
|
||||
/// up immediately against the `stub_confirmed` baseline.
|
||||
#[cfg(feature = "dynamic")]
|
||||
fn bench_composite_chain_reverify_replay_stable(c: &mut Criterion) {
|
||||
use nyx_scanner::chain::reverify;
|
||||
use nyx_scanner::dynamic::verify::VerifyOptions;
|
||||
use nyx_scanner::surface::SurfaceMap;
|
||||
|
||||
let surface = SurfaceMap::new();
|
||||
let opts = VerifyOptions {
|
||||
replay_stable_check: true,
|
||||
..VerifyOptions::default()
|
||||
};
|
||||
let reverifier = BenchConfirmedReverifier;
|
||||
|
||||
c.bench_function("composite_chain_reverify_replay_stable", |b| {
|
||||
b.iter(|| {
|
||||
let mut chains = [mk_synthetic_chain(0xC4A3, 3)];
|
||||
let _ = reverify::reverify_top_chains_with(
|
||||
&mut chains,
|
||||
&[],
|
||||
&surface,
|
||||
&opts,
|
||||
1,
|
||||
&reverifier,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
#[allow(dead_code)]
|
||||
fn bench_noop(_c: &mut Criterion) {}
|
||||
|
||||
// When dynamic feature is off, provide a stub so the binary still links.
|
||||
#[cfg(not(feature = "dynamic"))]
|
||||
fn bench_noop(c: &mut Criterion) {
|
||||
c.bench_function("dynamic_disabled_noop", |b| b.iter(|| ()));
|
||||
}
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
criterion_group!(
|
||||
dynamic,
|
||||
bench_harness_build_cold,
|
||||
bench_harness_build_warm,
|
||||
bench_sandbox_run_payload,
|
||||
bench_docker_image_build,
|
||||
bench_docker_exec_warm,
|
||||
bench_docker_payload_cost,
|
||||
bench_rust_harness_build_cold,
|
||||
bench_js_harness_build_cold,
|
||||
bench_go_harness_build_cold,
|
||||
bench_java_harness_build_cold,
|
||||
bench_php_harness_build_cold,
|
||||
bench_composite_chain_reverify_dispatch,
|
||||
bench_composite_chain_reverify_stub_confirmed,
|
||||
bench_composite_chain_reverify_top_n_slice,
|
||||
bench_composite_chain_reverify_replay_stable,
|
||||
);
|
||||
|
||||
#[cfg(not(feature = "dynamic"))]
|
||||
criterion_group!(dynamic, bench_noop);
|
||||
|
||||
criterion_main!(dynamic);
|
||||
26
benches/dynamic_bench_baseline.json
Normal file
26
benches/dynamic_bench_baseline.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"schema": 1,
|
||||
"note": "ASPIRATIONAL placeholder — values were hand-typed, not captured from a real bench run. Regenerate with: benches/regen_baseline.sh (requires --features dynamic and python3 on PATH). Commit the updated file to establish a real regression reference for M3+.",
|
||||
"benchmarks": {
|
||||
"harness_build_cold": {
|
||||
"mean_ns": 800000,
|
||||
"stddev_ns": 120000,
|
||||
"description": "Fresh workdir; spec → BuiltHarness including source gen + disk write."
|
||||
},
|
||||
"harness_build_warm": {
|
||||
"mean_ns": 180000,
|
||||
"stddev_ns": 30000,
|
||||
"description": "Workdir already staged; file write skipped by dst.exists() guard."
|
||||
},
|
||||
"sandbox_run_payload": {
|
||||
"mean_ns": 120000000,
|
||||
"stddev_ns": 15000000,
|
||||
"description": "Single process-backend run with sqli payload; includes python3 startup + settrace."
|
||||
}
|
||||
},
|
||||
"regression_thresholds": {
|
||||
"harness_build_cold": 2.0,
|
||||
"harness_build_warm": 2.0,
|
||||
"sandbox_run_payload": 1.5
|
||||
}
|
||||
}
|
||||
84
benches/regen_baseline.sh
Executable file
84
benches/regen_baseline.sh
Executable file
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regenerate benches/dynamic_bench_baseline.json from a real cargo bench run.
|
||||
#
|
||||
# Usage:
|
||||
# bash benches/regen_baseline.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - python3 on PATH
|
||||
# - cargo (nightly or stable with edition 2024)
|
||||
# - Criterion's JSON output (criterion feature already in dev-deps)
|
||||
#
|
||||
# The script runs the dynamic bench group, parses Criterion's estimates JSON,
|
||||
# and overwrites dynamic_bench_baseline.json with real numbers.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
BASELINE_FILE="${SCRIPT_DIR}/dynamic_bench_baseline.json"
|
||||
|
||||
echo "Running cargo bench --features dynamic -- dynamic ..."
|
||||
cargo bench --manifest-path "${REPO_ROOT}/Cargo.toml" \
|
||||
--features dynamic \
|
||||
-- dynamic \
|
||||
2>&1 | tee /tmp/nyx_bench_raw.txt
|
||||
|
||||
# Criterion writes estimates to target/criterion/<bench>/<group>/estimates.json.
|
||||
# Extract mean_ns for each tracked benchmark.
|
||||
extract_ns() {
|
||||
local path="$1"
|
||||
if [[ -f "${path}" ]]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.load(open('${path}'))
|
||||
mean = d['mean']['point_estimate']
|
||||
stddev = (d['std_dev']['point_estimate']) if 'std_dev' in d else 0
|
||||
print(int(mean), int(stddev))
|
||||
"
|
||||
else
|
||||
echo "0 0"
|
||||
fi
|
||||
}
|
||||
|
||||
TARGET="${REPO_ROOT}/target/criterion"
|
||||
|
||||
read COLD_MEAN COLD_STDDEV < <(extract_ns "${TARGET}/harness_build_cold/default/estimates.json")
|
||||
read WARM_MEAN WARM_STDDEV < <(extract_ns "${TARGET}/harness_build_warm/default/estimates.json")
|
||||
read RUN_MEAN RUN_STDDEV < <(extract_ns "${TARGET}/sandbox_run_payload/default/estimates.json")
|
||||
|
||||
MACHINE="$(uname -m) / $(uname -s)"
|
||||
NYX_VER="$(cargo metadata --manifest-path "${REPO_ROOT}/Cargo.toml" --no-deps --format-version 1 \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print(next(p['version'] for p in d['packages'] if p['name']=='nyx-scanner'))")"
|
||||
DATE="$(date +%Y-%m-%d)"
|
||||
|
||||
cat > "${BASELINE_FILE}" <<EOF
|
||||
{
|
||||
"schema": 1,
|
||||
"note": "Baseline captured on ${MACHINE}, nyx v${NYX_VER}, ${DATE}. Regenerate with: benches/regen_baseline.sh",
|
||||
"benchmarks": {
|
||||
"harness_build_cold": {
|
||||
"mean_ns": ${COLD_MEAN},
|
||||
"stddev_ns": ${COLD_STDDEV},
|
||||
"description": "Fresh workdir; spec → BuiltHarness including source gen + disk write."
|
||||
},
|
||||
"harness_build_warm": {
|
||||
"mean_ns": ${WARM_MEAN},
|
||||
"stddev_ns": ${WARM_STDDEV},
|
||||
"description": "Workdir already staged; file write skipped by dst.exists() guard."
|
||||
},
|
||||
"sandbox_run_payload": {
|
||||
"mean_ns": ${RUN_MEAN},
|
||||
"stddev_ns": ${RUN_STDDEV},
|
||||
"description": "Single process-backend run with sqli payload; includes python3 startup + settrace."
|
||||
}
|
||||
},
|
||||
"regression_thresholds": {
|
||||
"harness_build_cold": 2.0,
|
||||
"harness_build_warm": 2.0,
|
||||
"sandbox_run_payload": 1.5
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Updated ${BASELINE_FILE}"
|
||||
|
|
@ -11,6 +11,8 @@ preferred-dark-theme = "navy"
|
|||
git-repository-url = "https://github.com/elicpeter/nyx"
|
||||
edit-url-template = "https://github.com/elicpeter/nyx/edit/master/{path}"
|
||||
site-url = "/nyx/"
|
||||
additional-css = ["docs/mermaid.css"]
|
||||
additional-js = ["docs/mermaid-init.js"]
|
||||
|
||||
[output.html.fold]
|
||||
enable = true
|
||||
|
|
|
|||
366
build.rs
366
build.rs
|
|
@ -1,8 +1,21 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Only relevant when the serve feature is active
|
||||
// Phase 17 (Track E.1): always emit the seccomp policy table to
|
||||
// OUT_DIR. Gated runtime via `#[cfg(target_os = "linux")]`, but the
|
||||
// codegen runs on every host so `cargo check` on macOS still emits
|
||||
// the file (the include never actually compiles on non-Linux).
|
||||
emit_seccomp_policy();
|
||||
|
||||
// Phase 19 (Track E.3): emit the IMAGE_DIGESTS table from
|
||||
// tools/image-builder/images.toml. The runtime side (src/dynamic/
|
||||
// toolchain.rs) `include!`s the generated file unconditionally so
|
||||
// every host build has the same pinned-digest catalogue.
|
||||
emit_image_digests();
|
||||
|
||||
// Only relevant when the serve feature is active.
|
||||
if std::env::var("CARGO_FEATURE_SERVE").is_err() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -70,3 +83,354 @@ fn emit_placeholder_and_warn(dist_dir: &Path) {
|
|||
"cargo:warning=Node.js/npm not available — wrote placeholder frontend assets. Run 'cd frontend && npm install && npm run build' for the real UI."
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase 17 (Track E.1) — seccomp policy codegen ────────────────────────────
|
||||
|
||||
const SECCOMP_POLICY_PATH: &str = "src/dynamic/sandbox/seccomp/seccomp_policy.toml";
|
||||
|
||||
/// Cap-name → Cap bit value table. Mirrors the `bitflags!` block in
|
||||
/// `src/labels/mod.rs`. Keep in sync when adding/removing `Cap`
|
||||
/// constants.
|
||||
const CAP_BIT_FOR_NAME: &[(&str, u32)] = &[
|
||||
("ENV_VAR", 1 << 0),
|
||||
("HTML_ESCAPE", 1 << 1),
|
||||
("SHELL_ESCAPE", 1 << 2),
|
||||
("URL_ENCODE", 1 << 3),
|
||||
("JSON_PARSE", 1 << 4),
|
||||
("FILE_IO", 1 << 5),
|
||||
("FMT_STRING", 1 << 6),
|
||||
("SQL_QUERY", 1 << 7),
|
||||
("DESERIALIZE", 1 << 8),
|
||||
("SSRF", 1 << 9),
|
||||
("CODE_EXEC", 1 << 10),
|
||||
("CRYPTO", 1 << 11),
|
||||
("UNAUTHORIZED_ID", 1 << 12),
|
||||
("DATA_EXFIL", 1 << 13),
|
||||
("LDAP_INJECTION", 1 << 14),
|
||||
("XPATH_INJECTION", 1 << 15),
|
||||
("HEADER_INJECTION", 1 << 16),
|
||||
("OPEN_REDIRECT", 1 << 17),
|
||||
("SSTI", 1 << 18),
|
||||
("XXE", 1 << 19),
|
||||
("PROTOTYPE_POLLUTION", 1 << 20),
|
||||
];
|
||||
|
||||
fn emit_seccomp_policy() {
|
||||
println!("cargo:rerun-if-changed={}", SECCOMP_POLICY_PATH);
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR must be set by cargo");
|
||||
let out_path = Path::new(&out_dir).join("seccomp_policy.rs");
|
||||
|
||||
// Read the policy file; on missing file (e.g. fresh checkout on a
|
||||
// foreign target), emit empty tables so compilation still succeeds.
|
||||
let toml_text = match std::fs::read_to_string(SECCOMP_POLICY_PATH) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
std::fs::write(
|
||||
&out_path,
|
||||
"pub static BASE: &[&str] = &[];\npub static CAP: &[(u32, &[&str])] = &[];\n",
|
||||
)
|
||||
.expect("write empty seccomp policy stub");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let parsed = parse_seccomp_toml(&toml_text);
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str("// generated by build.rs from seccomp_policy.toml — do not edit\n\n");
|
||||
|
||||
// Base allowlist.
|
||||
out.push_str("pub static BASE: &[&str] = &[\n");
|
||||
for name in &parsed.base {
|
||||
out.push_str(&format!(" \"{}\",\n", escape(name)));
|
||||
}
|
||||
out.push_str("];\n\n");
|
||||
|
||||
// Per-cap allowlists.
|
||||
out.push_str("pub static CAP: &[(u32, &[&str])] = &[\n");
|
||||
for (cap_name, allow) in &parsed.caps {
|
||||
let bit = CAP_BIT_FOR_NAME
|
||||
.iter()
|
||||
.find(|(n, _)| *n == cap_name.as_str())
|
||||
.map(|(_, b)| *b)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"seccomp_policy.toml references unknown Cap '{cap_name}' — \
|
||||
add it to CAP_BIT_FOR_NAME in build.rs first"
|
||||
)
|
||||
});
|
||||
out.push_str(&format!(" (0x{bit:08x}_u32, &[\n"));
|
||||
for name in allow {
|
||||
out.push_str(&format!(" \"{}\",\n", escape(name)));
|
||||
}
|
||||
out.push_str(" ]),\n");
|
||||
}
|
||||
out.push_str("];\n");
|
||||
|
||||
std::fs::write(&out_path, out).expect("write seccomp policy table");
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SeccompPolicy {
|
||||
base: Vec<String>,
|
||||
caps: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Tiny line-oriented TOML parser scoped to the shape used by
|
||||
/// `seccomp_policy.toml`:
|
||||
///
|
||||
/// [base]
|
||||
/// allow = ["read", "write", ...]
|
||||
///
|
||||
/// [cap.SQL_QUERY]
|
||||
/// allow = [
|
||||
/// "fdatasync",
|
||||
/// ...
|
||||
/// ]
|
||||
///
|
||||
/// Comments (`#`) and blank lines are skipped. Multi-line array bodies
|
||||
/// are accumulated until the closing `]`.
|
||||
fn parse_seccomp_toml(src: &str) -> SeccompPolicy {
|
||||
let mut policy = SeccompPolicy::default();
|
||||
let mut current_section: Option<String> = None;
|
||||
let mut accumulating_array: Option<String> = None;
|
||||
let mut array_buf = String::new();
|
||||
|
||||
for raw_line in src.lines() {
|
||||
let line = strip_comment(raw_line).trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(_key) = accumulating_array.as_ref() {
|
||||
array_buf.push_str(line);
|
||||
array_buf.push('\n');
|
||||
if line.contains(']') {
|
||||
let key = accumulating_array.take().unwrap();
|
||||
let values = parse_string_array(&array_buf);
|
||||
store_allow(&mut policy, current_section.as_deref(), &key, values);
|
||||
array_buf.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
current_section = Some(section.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, rest)) = line.split_once('=') {
|
||||
let key = key.trim().to_string();
|
||||
let rest = rest.trim();
|
||||
if rest.starts_with('[') && rest.contains(']') {
|
||||
let values = parse_string_array(rest);
|
||||
store_allow(&mut policy, current_section.as_deref(), &key, values);
|
||||
} else if rest.starts_with('[') {
|
||||
accumulating_array = Some(key);
|
||||
array_buf.push_str(rest);
|
||||
array_buf.push('\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
policy
|
||||
}
|
||||
|
||||
fn strip_comment(line: &str) -> &str {
|
||||
let mut in_string = false;
|
||||
let bytes = line.as_bytes();
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
match b {
|
||||
b'"' => in_string = !in_string,
|
||||
b'#' if !in_string => return &line[..i],
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
fn parse_string_array(src: &str) -> Vec<String> {
|
||||
// Find every "..." run between the first `[` and the last `]`.
|
||||
let start = src.find('[').map(|i| i + 1).unwrap_or(0);
|
||||
let end = src.rfind(']').unwrap_or(src.len());
|
||||
let body = &src[start..end];
|
||||
let mut out = Vec::new();
|
||||
let mut chars = body.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '"' {
|
||||
let mut s = String::new();
|
||||
for c2 in chars.by_ref() {
|
||||
if c2 == '"' {
|
||||
break;
|
||||
}
|
||||
s.push(c2);
|
||||
}
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn store_allow(policy: &mut SeccompPolicy, section: Option<&str>, key: &str, values: Vec<String>) {
|
||||
if key != "allow" {
|
||||
return;
|
||||
}
|
||||
match section {
|
||||
Some("base") => policy.base = values,
|
||||
Some(other) => {
|
||||
if let Some(cap_name) = other.strip_prefix("cap.") {
|
||||
policy.caps.insert(cap_name.to_string(), values);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
// ── Phase 19 (Track E.3) — image digest codegen ──────────────────────────────
|
||||
|
||||
const IMAGE_CATALOGUE_PATH: &str = "tools/image-builder/images.toml";
|
||||
|
||||
/// Parse `tools/image-builder/images.toml` and emit two tables to
|
||||
/// `$OUT_DIR/image_digests.rs`:
|
||||
///
|
||||
/// pub static IMAGE_DIGESTS: phf::Map<&'static str, &'static str> = …;
|
||||
/// pub static IMAGE_BASES: phf::Map<&'static str, &'static str> = …;
|
||||
///
|
||||
/// `IMAGE_DIGESTS` keys are toolchain IDs (`python-3.11`, …) and values are
|
||||
/// `<base>@sha256:…` strings ready to hand to `docker pull`. An empty digest
|
||||
/// in `images.toml` is treated as "not yet pinned" and the entry is omitted
|
||||
/// from `IMAGE_DIGESTS`; `IMAGE_BASES` always carries the unpinned reference
|
||||
/// so `docker.rs` can fall back to a tag pull when no digest is recorded.
|
||||
fn emit_image_digests() {
|
||||
println!("cargo:rerun-if-changed={}", IMAGE_CATALOGUE_PATH);
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR must be set by cargo");
|
||||
let out_path = Path::new(&out_dir).join("image_digests.rs");
|
||||
|
||||
let toml_text = match std::fs::read_to_string(IMAGE_CATALOGUE_PATH) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
// Missing catalogue (fresh checkout without the file) — emit
|
||||
// empty maps so the runtime include still compiles.
|
||||
std::fs::write(
|
||||
&out_path,
|
||||
"/// generated empty IMAGE_DIGESTS — images.toml missing\n\
|
||||
pub static IMAGE_DIGESTS: phf::Map<&'static str, &'static str> = \
|
||||
phf::phf_map! {};\n\
|
||||
pub static IMAGE_BASES: phf::Map<&'static str, &'static str> = \
|
||||
phf::phf_map! {};\n",
|
||||
)
|
||||
.expect("write empty image digests stub");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let entries = parse_image_catalogue(&toml_text);
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str("// generated by build.rs from tools/image-builder/images.toml — do not edit\n\n");
|
||||
|
||||
// IMAGE_DIGESTS: only entries with a non-empty digest survive.
|
||||
out.push_str(
|
||||
"pub static IMAGE_DIGESTS: phf::Map<&'static str, &'static str> = phf::phf_map! {\n",
|
||||
);
|
||||
for e in &entries {
|
||||
if e.digest.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pinned = format!("{}@{}", e.base, e.digest);
|
||||
out.push_str(&format!(
|
||||
" \"{}\" => \"{}\",\n",
|
||||
escape(&e.toolchain_id),
|
||||
escape(&pinned),
|
||||
));
|
||||
}
|
||||
out.push_str("};\n\n");
|
||||
|
||||
// IMAGE_BASES: every entry, digest stripped. Used by docker.rs when no
|
||||
// digest is pinned yet so a `docker pull <base>` is still possible.
|
||||
out.push_str(
|
||||
"pub static IMAGE_BASES: phf::Map<&'static str, &'static str> = phf::phf_map! {\n",
|
||||
);
|
||||
for e in &entries {
|
||||
out.push_str(&format!(
|
||||
" \"{}\" => \"{}\",\n",
|
||||
escape(&e.toolchain_id),
|
||||
escape(&e.base),
|
||||
));
|
||||
}
|
||||
out.push_str("};\n");
|
||||
|
||||
std::fs::write(&out_path, out).expect("write image_digests.rs");
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ImageEntry {
|
||||
toolchain_id: String,
|
||||
base: String,
|
||||
digest: String,
|
||||
}
|
||||
|
||||
/// Tiny TOML parser scoped to the `[[image]] toolchain_id = …` shape used
|
||||
/// by `images.toml`. Only the three fields we consume here are extracted;
|
||||
/// the rest of each entry (`toolchain`, `packages`) is ignored.
|
||||
fn parse_image_catalogue(src: &str) -> Vec<ImageEntry> {
|
||||
let mut entries: Vec<ImageEntry> = Vec::new();
|
||||
let mut current: Option<ImageEntry> = None;
|
||||
|
||||
for raw_line in src.lines() {
|
||||
let line = strip_comment(raw_line).trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "[[image]]" {
|
||||
if let Some(prev) = current.take()
|
||||
&& !prev.toolchain_id.is_empty()
|
||||
{
|
||||
entries.push(prev);
|
||||
}
|
||||
current = Some(ImageEntry::default());
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with("[[") || line.starts_with('[') {
|
||||
// Any other section ends accumulation.
|
||||
if let Some(prev) = current.take()
|
||||
&& !prev.toolchain_id.is_empty()
|
||||
{
|
||||
entries.push(prev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(slot) = current.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_matches('"').trim_matches('\'');
|
||||
match key {
|
||||
"toolchain_id" => slot.toolchain_id = value.to_owned(),
|
||||
"base" => slot.base = value.to_owned(),
|
||||
"digest" => slot.digest = value.to_owned(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(prev) = current.take()
|
||||
&& !prev.toolchain_id.is_empty()
|
||||
{
|
||||
entries.push(prev);
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,21 @@ enable_state_analysis = true
|
|||
## Per-language auth overrides live under [analysis.languages.<slug>.auth].
|
||||
enable_auth_analysis = true
|
||||
|
||||
## Run dynamic verification on Medium/High confidence findings after static analysis.
|
||||
## Default builds include this support. Use --no-verify or set this false for
|
||||
## fast static-only scans, or when building with --no-default-features.
|
||||
verify = true
|
||||
|
||||
## Also verify Low-confidence findings. Slower; intended for payload tuning.
|
||||
verify_all_confidence = false
|
||||
|
||||
## Dynamic sandbox backend: auto | docker | process | firecracker
|
||||
## auto uses Docker when available, otherwise the process backend.
|
||||
verify_backend = "auto"
|
||||
|
||||
## Process-backend hardening profile: standard | strict
|
||||
harden_profile = "standard"
|
||||
|
||||
## Catch per-file panics during analysis and continue the scan.
|
||||
## When false (default), a panic in one file's analyser aborts the whole
|
||||
## scan — useful for catching engine bugs loudly in development.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
- [CLI reference](cli.md)
|
||||
- [Browser UI](serve.md)
|
||||
- [Dynamic verification](dynamic.md)
|
||||
- [Configuration](configuration.md)
|
||||
- [Output formats](output.md)
|
||||
|
||||
|
|
|
|||
|
|
@ -267,11 +267,11 @@ while the pass stabilises.
|
|||
| CLI flag | `--backwards-analysis` / `--no-backwards-analysis` |
|
||||
| Env var (legacy) | `NYX_BACKWARDS=1` |
|
||||
|
||||
**Limitations (first cut).** Reverse call-graph expansion past a
|
||||
`ReachedParam` is deferred; the walk terminates at function parameters
|
||||
rather than crossing back into callers. Path-constraint pruning is
|
||||
conservative: only the accumulated `PredicateSummary` bits are consulted,
|
||||
not the full symbolic predicate stack. Depth-bounded at k=2 for
|
||||
**Limitations.** Reverse call-graph expansion stops at `ReachedParam`; the walk
|
||||
terminates at function parameters rather than crossing back into callers.
|
||||
Path-constraint pruning is conservative: only the accumulated
|
||||
`PredicateSummary` bits are consulted, not the full symbolic predicate stack.
|
||||
Depth-bounded at k=2 for
|
||||
cross-function body expansion. See `DEFAULT_BACKWARDS_DEPTH`,
|
||||
`BACKWARDS_VALUE_BUDGET`, and `MAX_BACKWARDS_CALLEE_BLOCKS` in
|
||||
`src/taint/backwards.rs` for the exact bounds.
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ When a private helper is called only from authorized route handlers in the same
|
|||
|
||||
- Iterated to a small fixpoint so transitive chains (route to mid_helper to leaf_helper) are covered.
|
||||
- Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers.
|
||||
- Cross-file equivalent is deferred.
|
||||
- Cross-file caller-scope lifting is not implemented yet.
|
||||
|
||||
This closes the FastAPI / Django / Flask shape where a route authenticates via decorator or dependency, then delegates to a private helper that performs the sink.
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ Matched as last-segment + case-insensitive `starts_with` (so a single entry `"Gu
|
|||
|
||||
### Recognised actor names
|
||||
|
||||
Recognised by default: `user.id`, `user.user_id`, `user.uid`, `session.user_id`, `current_user.id`, plus typed extractor parameters with `CurrentUser`, `SessionUser`, `AuthUser`, `Extension<...>` shapes. To add a custom binding pattern, file an issue or add a fixture; the heuristic is in [`src/auth_analysis/checks.rs`](https://github.com/elicpeter/nyx/blob/master/src/auth_analysis/checks.rs) under `extract_validation_target` and friends.
|
||||
Recognised by default: `user.id`, `user.user_id`, `user.uid`, `session.user_id`, `current_user.id`, plus typed extractor parameters with `CurrentUser`, `SessionUser`, `AuthUser`, `Extension<...>` shapes. To add a custom binding pattern, file an issue or add a fixture; the heuristic lives in [`src/auth_analysis/extract/common.rs`](https://github.com/elicpeter/nyx/blob/master/src/auth_analysis/extract/common.rs) under the `*self_actor*` helpers (`collect_self_actor_binding`, `collect_typed_extractor_self_actor`, `is_self_actor_type_text`).
|
||||
|
||||
### Suppress
|
||||
|
||||
|
|
|
|||
82
docs/cli.md
82
docs/cli.md
|
|
@ -74,7 +74,7 @@ nyx scan [PATH] [OPTIONS]
|
|||
| `--fail-on <SEV>` | *(none)* | Exit code 1 if any finding >= this severity |
|
||||
| `--show-suppressed` | off | Show inline-suppressed findings (dimmed, tagged `[SUPPRESSED]`) |
|
||||
| `--keep-nonprod-severity` | off | Don't downgrade severity for test/vendor paths |
|
||||
| `--all` | off | Disable category filtering, rollups, and LOW budgets -- show everything |
|
||||
| `--all` | off | Disable category filtering, rollups, and LOW budgets. Shows everything |
|
||||
| `--include-quality` | off | Include Quality-category findings (hidden by default) |
|
||||
| `--max-low <N>` | `20` | Maximum total LOW findings to show |
|
||||
| `--max-low-per-file <N>` | `1` | Maximum LOW findings per file |
|
||||
|
|
@ -152,6 +152,28 @@ nyx scan --engine-profile deep --no-smt --explain-engine
|
|||
|
||||
<p align="center"><img src="assets/screenshots/docs/cli-explain-engine.png" alt="nyx scan --engine-profile deep --explain-engine output: resolved config showing every analysis pass, its current state, and the CLI flag/env var that controls it" width="900"/></p>
|
||||
|
||||
### Dynamic verification
|
||||
|
||||
Available in default builds, or in custom builds with `--features dynamic`. See [dynamic.md](dynamic.md) for the full pipeline and verdict semantics.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--verify` | on | Enable dynamic verification (default when built with `dynamic`). Conflicts with `--no-verify` |
|
||||
| `--no-verify` | off | Skip verification for this run. Useful for fast static-only scans without editing config |
|
||||
| `--verify-all-confidence` | off | Also verify findings below `Confidence >= Medium`. Slower; intended for payload tuning |
|
||||
| `--backend <BACKEND>` | `auto` | Sandbox backend: `auto` (docker if available, else process), `docker` (required), `process` (in-process runner) |
|
||||
| `--unsafe-sandbox` | off | Force the process backend. Equivalent to `--backend process`. Cannot combine with `--backend docker` |
|
||||
| `--harden <PROFILE>` | `standard` | Process-backend lockdown: `standard` (no-new-privs + rlimit on Linux) or `strict` (namespaces + chroot + seccomp on Linux; `sandbox-exec` on macOS) |
|
||||
| `--verbose` | off | Flush the per-finding `VerifyTrace` to stderr after each verdict. Same stream that lands in `expected/trace.jsonl` in the repro bundle |
|
||||
|
||||
### Baseline / patch validation
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--baseline <FILE>` | *(none)* | Read a prior scan's JSON (or a stripped `.nyx/baseline.json`) and diff it against this scan on `stable_hash`. Reports `New` / `Resolved` / `FlippedConfirmed` / `FlippedNotConfirmed` transitions |
|
||||
| `--baseline-write <FILE>` | *(none)* | After scanning, write a stripped baseline (only `stable_hash`, `dynamic_verdict`, `severity`, `path`, `rule_id`; no source). Safe to commit |
|
||||
| `--gate <GATE>` | *(none)* | CI gate to enforce when `--baseline` is active. `no-new-confirmed` exits 2 on any new Confirmed finding; `resolve-all-confirmed` exits 2 if any baseline-Confirmed finding is not fully resolved |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
|
|
@ -248,6 +270,64 @@ Remove index data.
|
|||
|
||||
---
|
||||
|
||||
## `nyx surface`
|
||||
|
||||
Print the project's attack-surface map.
|
||||
|
||||
```
|
||||
nyx surface [PATH] [--format <FMT>] [--build]
|
||||
```
|
||||
|
||||
Loads the `SurfaceMap` persisted by the most recent indexed scan when available; otherwise runs the per-language framework probes against the on-disk source to produce an entry-points-only map. Pass `--build` to force a full inline build (pass-1 summary extraction + call-graph construction) on an unscanned project, which adds `DataStore` / `ExternalService` / `DangerousLocal` nodes the entry-points-only fallback omits.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--format <FMT>` | `text` | Output format: `text` (indented tree), `json` (canonical SurfaceMap), `dot` (Graphviz source), or `svg` (spawns `dot` locally) |
|
||||
| `--build` | off | Force a full SurfaceMap build inline when no indexed scan exists. Same cost as `nyx index build` |
|
||||
|
||||
Pipe `dot` output through `dot -Tsvg` for a renderable graph, or use `--format svg` for a one-step render when graphviz is installed.
|
||||
|
||||
---
|
||||
|
||||
## `nyx serve`
|
||||
|
||||
Start the local browser UI for browsing scan results.
|
||||
|
||||
```
|
||||
nyx serve [PATH] [OPTIONS]
|
||||
```
|
||||
|
||||
**PATH** defaults to `.` (current directory). The server binds to a loopback address only and refuses non-loopback hosts at startup.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `-p, --port <PORT>` | *(from config)* | Port to bind to (overrides `[server].port`) |
|
||||
| `--host <HOST>` | *(from config)* | Host to bind to (overrides `[server].host`) |
|
||||
| `--no-browser` | off | Skip opening the browser automatically |
|
||||
|
||||
See [serve.md](serve.md) for the UI tour, route map, and CSRF / host-header behaviour.
|
||||
|
||||
---
|
||||
|
||||
## `nyx verify-feedback`
|
||||
|
||||
Record a correction or confirmation against a dynamic-verifier verdict. Requires `--features dynamic`.
|
||||
|
||||
```
|
||||
nyx verify-feedback <FINDING_ID> [--wrong <REASON> | --right] [--upload]
|
||||
```
|
||||
|
||||
| Argument/Flag | Description |
|
||||
|---------------|-------------|
|
||||
| `FINDING_ID` | Stable 16-char hex id shown in `nyx scan --verify` output |
|
||||
| `--wrong <REASON>` | Mark the verdict wrong and record the reason. Conflicts with `--right` |
|
||||
| `--right` | Confirm the verdict. Conflicts with `--wrong` |
|
||||
| `--upload` | Reserved; uploading to Nyx telemetry is not yet implemented |
|
||||
|
||||
Feedback is written to the local telemetry log under the platform cache dir.
|
||||
|
||||
---
|
||||
|
||||
## `nyx config`
|
||||
|
||||
Manage configuration.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ Run `nyx config path` to see the exact directory on your system.
|
|||
|
||||
## File Precedence
|
||||
|
||||
1. **`nyx.conf`** -- Default config (auto-created from built-in template on first run)
|
||||
2. **`nyx.local`** -- User overrides (loaded on top of defaults)
|
||||
1. **`nyx.conf`**: default config (auto-created from built-in template on first run)
|
||||
2. **`nyx.local`**: user overrides (loaded on top of defaults)
|
||||
|
||||
Both files are optional. CLI flags take precedence over both.
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ excluded_extensions = ["jpg", "png", "exe"]
|
|||
excluded_extensions = ["foo", "jpg"]
|
||||
|
||||
# Effective result:
|
||||
# ["exe", "foo", "jpg", "png"] -- sorted, deduped union
|
||||
# ["exe", "foo", "jpg", "png"] (sorted, deduped union)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -65,6 +65,13 @@ excluded_extensions = ["foo", "jpg"]
|
|||
| `scan_hidden_files` | bool | `false` | Scan dot-files |
|
||||
| `include_nonprod` | bool | `false` | Keep original severity for test/vendor paths |
|
||||
| `enable_state_analysis` | bool | `true` | Enable resource lifecycle + auth state analysis. Detects use-after-close, double-close, resource leaks (per-function scope), and unauthenticated access. Requires `mode = "full"` or `mode = "taint"`. |
|
||||
| `enable_auth_analysis` | bool | `true` | Enable auth-state analysis within the state engine. When false, only resource lifecycle findings (leak, use-after-close, double-close) are produced. |
|
||||
| `enable_panic_recovery` | bool | `false` | Catch per-file analysis panics as warnings and continue. When false, a panic aborts the scan, preserving the loud-fail behaviour for users debugging engine bugs. |
|
||||
| `enable_auth_as_taint` | bool | `false` | Fold auth analysis into the SSA/taint engine via `Cap::UNAUTHORIZED_ID`. Off while the standalone path still carries stable detection. |
|
||||
| `verify` | bool | `true` | Run dynamic verification on each `Confidence >= Medium` finding after the static pass. Included in default builds; custom `--no-default-features` builds need `--features dynamic`. CLI overrides: `--verify` / `--no-verify`. |
|
||||
| `verify_all_confidence` | bool | `false` | Extend dynamic verification to findings below `Confidence::Medium`. Intended for corpus-building, not production scans. CLI: `--verify-all-confidence`. |
|
||||
| `verify_backend` | string | `"auto"` | Sandbox backend for dynamic verification. `"auto"` picks docker when available else process; `"docker"` requires docker; `"process"` runs in-process (same as `--unsafe-sandbox`). |
|
||||
| `harden_profile` | string | `"standard"` | Process-backend hardening profile. `"standard"` engages `PR_SET_NO_NEW_PRIVS` + `setrlimit(RLIMIT_AS)` on Linux; `"strict"` adds namespace unshare, chroot to workdir, and a default-deny seccomp filter on Linux, plus `sandbox-exec` wrapping on macOS keyed off the finding's expected cap. |
|
||||
|
||||
### `[database]`
|
||||
|
||||
|
|
@ -119,6 +126,7 @@ Configuration for the local web UI (`nyx serve`).
|
|||
| `auto_reload` | bool | `true` | Auto-reload UI when scan results change |
|
||||
| `persist_runs` | bool | `true` | Persist scan runs for history view |
|
||||
| `max_saved_runs` | int | `50` | Maximum number of saved runs |
|
||||
| `triage_sync` | bool | `true` | Auto-sync triage decisions to `.nyx/triage.json` in the project root so changes can be committed to git. |
|
||||
|
||||
### `[runs]`
|
||||
|
||||
|
|
@ -173,10 +181,10 @@ Release-grade switches for the optional analysis passes. Each toggle has a
|
|||
matching CLI flag (pair of `--foo` / `--no-foo`) that overrides the config
|
||||
value for a single run. These used to be `NYX_*` environment variables
|
||||
(`NYX_CONSTRAINT`, `NYX_ABSTRACT_INTERP`, `NYX_SYMEX`, `NYX_CROSS_FILE_SYMEX`,
|
||||
`NYX_SYMEX_INTERPROC`, `NYX_CONTEXT_SENSITIVE`, `NYX_PARSE_TIMEOUT_MS`,
|
||||
`NYX_SMT`); those env vars are still honored as a last-resort override when
|
||||
nyx is used as a library (no CLI entry point), but the config/CLI surface is
|
||||
the stable path.
|
||||
`NYX_SYMEX_INTERPROC`, `NYX_CONTEXT_SENSITIVE`, `NYX_BACKWARDS`,
|
||||
`NYX_PARSE_TIMEOUT_MS`, `NYX_SMT`); those env vars are still honored as a
|
||||
fallback default when nyx is used as a library (no CLI entry point), but the
|
||||
config/CLI surface is the stable path.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
|
|
@ -185,6 +193,8 @@ the stable path.
|
|||
| `context_sensitive` | bool | `true` | k=1 context-sensitive callee inlining for intra-file calls |
|
||||
| `backwards_analysis` | bool | `false` | Demand-driven backwards taint walk from sinks (adds scan time; default off) |
|
||||
| `parse_timeout_ms` | int | `10000` | Per-file tree-sitter parse timeout; `0` disables the cap |
|
||||
| `max_origins` | int | `32` | Maximum taint origins retained per lattice value. Excess origins are dropped deterministically (sorted by source location) and an `OriginsTruncated` engine note is recorded. CLI: `--max-origins`. |
|
||||
| `max_pointsto` | int | `32` | Maximum abstract heap objects retained per intra-procedural points-to set. Excess objects are dropped and a `PointsToTruncated` engine note is recorded. CLI: `--max-pointsto`. |
|
||||
|
||||
**`[analysis.engine.symex]`** sub-section:
|
||||
|
||||
|
|
@ -208,11 +218,33 @@ CLI flag map (each pair is `--enable / --no-enable`):
|
|||
| `symex.cross_file` | `--cross-file-symex` / `--no-cross-file-symex` |
|
||||
| `symex.interprocedural` | `--symex-interproc` / `--no-symex-interproc` |
|
||||
| `symex.smt` | `--smt` / `--no-smt` |
|
||||
| `max_origins` | `--max-origins <N>` |
|
||||
| `max_pointsto` | `--max-pointsto <N>` |
|
||||
|
||||
**Engine-depth profile shortcut**: instead of flipping individual toggles, pass `--engine-profile {fast,balanced,deep}` to set the whole stack at once. Individual flags override the profile, so `--engine-profile fast --backwards-analysis` runs the fast stack with backwards analysis on. See `docs/cli.md` for the exact toggle matrix.
|
||||
|
||||
**Explain effective engine**: pass `--explain-engine` to print the resolved engine configuration (profile + config + CLI overrides) and exit without scanning.
|
||||
|
||||
### `[chain]`
|
||||
|
||||
Bounded-DFS path search across taint findings. Emits multi-step attack chains when several findings link through shared SSA values or call edges.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `max_depth` | int | `4` | Maximum per-finding hops in a single chain path. |
|
||||
| `min_score` | float | `9.5` | Score threshold; chains below this value are dropped. |
|
||||
| `reverify_top_n` | int | `5` | Only the top-N chains by score are eligible for composite dynamic re-verification. `0` disables composite re-verification. |
|
||||
|
||||
### `[telemetry]`
|
||||
|
||||
Sampling policy for the on-disk event log written by dynamic verification (`~/.cache/nyx/dynamic/events.jsonl`). Confirmed and Inconclusive verdicts are calibration-critical and kept by default; other verdict statuses can be downsampled to bound log growth. Decisions are seeded by `spec_hash` for determinism. See `docs/dynamic.md` for the on-disk schema and `NYX_NO_TELEMETRY=1` opt-out.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `keep_all_confirmed` | bool | `true` | Always retain `Confirmed` verdicts. |
|
||||
| `keep_all_inconclusive` | bool | `true` | Always retain `Inconclusive` verdicts. |
|
||||
| `sample_rate_other` | float | `1.0` | Retention probability for verdicts not covered by the keep-all flags. `1.0` keeps everything, `0.0` drops everything. |
|
||||
|
||||
### `[detectors.data_exfil]`
|
||||
|
||||
Per-project tuning for the `taint-data-exfiltration` rule. All fields are optional.
|
||||
|
|
@ -354,7 +386,7 @@ nyx config show
|
|||
|
||||
Config is validated after loading and merging. Validation checks include:
|
||||
|
||||
- Server port must be 1–65535
|
||||
- Server port must be 1 to 65535
|
||||
- Server host must not be empty
|
||||
- `max_saved_runs` must be > 0 when `persist_runs` is true
|
||||
- `max_runs` must be > 0 when `persist` is true
|
||||
|
|
@ -391,9 +423,9 @@ State analysis requires `mode = "full"` or `mode = "taint"`. It has no effect in
|
|||
### Engine-version mismatch is handled automatically
|
||||
|
||||
Nyx stores the scanner's `CARGO_PKG_VERSION` in the project index database.
|
||||
When the version recorded in the DB differs from the running binary; or the
|
||||
row is missing entirely; every cached summary, SSA body, and file-hash row
|
||||
is wiped on the next open so the next scan rebuilds the index against the new
|
||||
When the version recorded in the DB differs from the running binary, or the
|
||||
row is missing entirely, every cached summary, SSA body, and file-hash row
|
||||
is wiped on the next open. The next scan rebuilds the index against the new
|
||||
engine. No flag is needed; CI pipelines keep working across upgrades.
|
||||
|
||||
The rebuild is logged at `info` level:
|
||||
|
|
@ -436,4 +468,4 @@ On the next scan Nyx builds a fresh index from scratch.
|
|||
|
||||
## Reserved Fields
|
||||
|
||||
Some config fields are defined but not yet implemented. They are marked `(RESERVED)` in the default config and accept values without effect. This allows forward-compatible config files; settings will activate when the feature is implemented without requiring config changes.
|
||||
Some config fields are defined but not yet implemented. They are marked `(RESERVED)` in the default config and accept values without effect. Config files stay forward-compatible: settings start having an effect when the feature ships, with no edit needed.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@ Nyx ships four independent detector families. They run together in `--mode full`
|
|||
| [State model](detectors/state.md) | `state-*` | Per-function state lattice | Use-after-close, double-close, leaks, unauthenticated access |
|
||||
| [AST patterns](detectors/patterns.md) | `<lang>.<cat>.<name>` | Tree-sitter structural match | Banned APIs, weak crypto, dangerous constructs |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Taint["Taint analysis<br/>cross-file source-to-sink"] --> Normalize["Normalize findings"]
|
||||
Cfg["CFG structural<br/>guards, exits, resource paths"] --> Normalize
|
||||
State["State model<br/>resource and auth lattice"] --> Normalize
|
||||
Ast["AST patterns<br/>tree-sitter structural match"] --> Normalize
|
||||
Normalize --> Dedupe["Deduplicate<br/>same site, rule, severity"]
|
||||
Dedupe --> Rank["Rank<br/>severity, evidence, context"]
|
||||
Rank --> Output["Console, JSON, SARIF, UI"]
|
||||
```
|
||||
|
||||
The taint family is split into cap-specific rule classes when a sink callee carries multiple vulnerability classes:
|
||||
|
||||
| Rule id | Cap | Surface |
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Higher confidence:
|
|||
Lower confidence:
|
||||
- Path-validated taint (`path_validated: true`).
|
||||
- Source is a database read or internal file (pre-validated at insertion is common).
|
||||
- Engine note `ForwardBailed` / `PathWidened`. Use `--require-converged` to drop these in strict gates.
|
||||
- Any non-informational engine note (`SsaLoweringBailed`, `ParseTimeout`, `PredicateStateWidened`, `PathEnvCapped`, `WorklistCapped`, etc.). Use `--require-converged` to drop over-report and bail notes in strict gates.
|
||||
|
||||
## Tuning
|
||||
|
||||
|
|
|
|||
380
docs/dynamic.md
Normal file
380
docs/dynamic.md
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
# Dynamic verification
|
||||
|
||||
Static analysis tells you a sink is reachable from a source. Dynamic
|
||||
verification tries to prove it. When verification is on, Nyx builds a small
|
||||
harness around each finding, runs it in a sandbox against a curated payload
|
||||
set, and stamps the result onto `evidence.dynamic_verdict`.
|
||||
|
||||
It is a second signal, not a replacement for review. A `Confirmed` verdict
|
||||
means Nyx triggered the sink in its harness with an attacker-controlled
|
||||
payload and proved the benign control stayed clean. `NotConfirmed` means the
|
||||
harness ran but nothing fired. Neither verdict closes a finding on its own.
|
||||
|
||||
Default Nyx builds include the `dynamic` feature. Custom
|
||||
`--no-default-features` builds run static-only unless rebuilt with
|
||||
`--features dynamic`.
|
||||
|
||||
## How confirmation works
|
||||
|
||||
Every cap that can be verified ships a curated corpus of payload pairs: at
|
||||
least one vulnerable payload and one benign control. The verifier runs both
|
||||
through the same harness and compares.
|
||||
|
||||
- The vulnerable payload must fire the sink. A payload "fires" when an
|
||||
oracle predicate matches the observed behavior, not when a string appears
|
||||
in the output.
|
||||
- The benign control must stay clean. It exercises the same code path with a
|
||||
value that a correct implementation handles safely.
|
||||
|
||||
A finding is `Confirmed` only when at least one vulnerable payload fires and
|
||||
every paired benign control stays clean. This differential rule is what keeps
|
||||
the verifier from confirming a finding just because the harness echoed an
|
||||
input.
|
||||
|
||||
Oracles are behavioral, scoped to the cap:
|
||||
|
||||
| Cap | Oracle | What it observes |
|
||||
| --- | --- | --- |
|
||||
| Command/code injection | stub event | the harness's exec boundary saw the injected command |
|
||||
| SQL injection | stub event | the SQL boundary saw the injected clause |
|
||||
| SSRF, data exfil | outbound host | the request left for a host outside the allowlist |
|
||||
| Path traversal | stub event | the filesystem boundary opened a path outside the root |
|
||||
| Template injection | template eval | `{{7*7}}` rendered as `49`, not echoed as text |
|
||||
| Deserialization | gadget marker | a non-allowlisted class was resolved during decode |
|
||||
| XXE | entity expansion | an external entity was expanded by the parser |
|
||||
| LDAP / XPath injection | result count | the malicious filter returned more rows than the benign one |
|
||||
| Header / CRLF | header split | an injected `\r\n` split or added a response header |
|
||||
| Open redirect | redirect host | the `Location` header pointed off-origin |
|
||||
| Prototype pollution | canary touch | a property write reached `Object.prototype` |
|
||||
| Weak crypto | key entropy | the produced key fit inside a 16-bit search space |
|
||||
| JSON parse abuse | parse depth | the parser accepted a depth past its limit |
|
||||
| IDOR | ownership cross | the read crossed from the caller's id to another owner's |
|
||||
|
||||
Every canary is derived per-run from `BLAKE3(spec_hash || run_nonce)`, so it is
|
||||
unique per finding, collision-resistant against ambient harness output, and
|
||||
never appears on the host.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
nyx scan # verifies Medium and High confidence findings
|
||||
nyx scan --no-verify # static analysis only
|
||||
nyx scan --verify # explicit form of the default behavior
|
||||
nyx scan --verify-all-confidence # also verify Low-confidence findings
|
||||
```
|
||||
|
||||
Use `--no-verify` for fast local checks or editor workflows. Keep
|
||||
verification on for CI when scan time allows it. `--verify-all-confidence` is
|
||||
slower and noisier; reach for it when tuning payloads or chasing coverage.
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `Confirmed` | A vulnerable payload fired the sink and every benign control stayed clean. |
|
||||
| `PartiallyConfirmed` | The sink was reached but no oracle marker was observed. The exploit chain did not complete. Treat as a strong lead, not a proof. |
|
||||
| `NotConfirmed` | The harness ran but no payload fired. The path is likely infeasible or the corpus does not cover this shape. The original finding stays open until reviewed. |
|
||||
| `Inconclusive` | Nyx could not finish the check. Carries a typed reason (build failed, spec derivation failed, sandbox error, policy denied, and others). |
|
||||
| `Unsupported` | Nyx did not attempt the finding. Carries a typed reason (language unsupported, entry kind unsupported, no payloads for cap, confidence below threshold, no sound oracle). |
|
||||
|
||||
When a `Confirmed` sink sits behind a recognized input-validation or
|
||||
output-sanitization guard (Spring `@PreAuthorize`, Express `helmet`, Nest
|
||||
`@UseGuards`, Django `@permission_classes`), the verdict demotes to
|
||||
`ConfirmedWithKnownGuard` and the guard names land on
|
||||
`differential.known_guards`. Authentication-only filters do not trigger the
|
||||
demotion, since they do not mitigate injection.
|
||||
|
||||
`PartiallyConfirmed` is deliberate. It marks the cases where engine work can
|
||||
ratchet without the tool overstating what it proved.
|
||||
|
||||
## Capability coverage
|
||||
|
||||
Caps split into two groups. Data-style injection (SQL, command, path,
|
||||
SSRF, XSS) uses language-neutral payload bytes (`' OR 1=1--`, `../../etc/passwd`,
|
||||
a callback URL), so the harness emitter for any language can carry them. The
|
||||
caps below have language-specific payloads (a Java gadget chain is not a
|
||||
Python pickle), so each language is curated on its own.
|
||||
|
||||
A checkmark means a tuned per-language payload set ships for that cell. Cells
|
||||
without a checkmark in the data-style rows still run, falling back to the
|
||||
language-neutral payload union.
|
||||
|
||||
| Cap | Py | JS | TS | Java | PHP | Ruby | Go | Rust | C | C++ |
|
||||
| --- | -- | -- | -- | ---- | --- | ---- | -- | ---- | - | --- |
|
||||
| Command / code injection | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| SQL injection | union | union | union | union | union | union | union | ✓ | union | union |
|
||||
| Path traversal | union | union | union | union | union | union | union | ✓ | union | union |
|
||||
| SSRF | union | union | union | union | union | union | union | ✓ | union | union |
|
||||
| XSS | union | union | union | union | union | union | union | ✓ | union | union |
|
||||
| Format string | | | | | | | | | ✓ | |
|
||||
| Deserialization | ✓ | | | ✓ | ✓ | ✓ | | | | |
|
||||
| Template injection | ✓ | ✓ | | ✓ | ✓ | ✓ | | | | |
|
||||
| XXE | ✓ | | | ✓ | ✓ | ✓ | ✓ | | | |
|
||||
| LDAP injection | ✓ | | | ✓ | ✓ | | | | | |
|
||||
| XPath injection | ✓ | ✓ | | ✓ | ✓ | | | | | |
|
||||
| Header / CRLF | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | | |
|
||||
| Open redirect | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | | |
|
||||
| Prototype pollution | | ✓ | ✓ | | | | | | | |
|
||||
| Weak crypto | ✓ | | | ✓ | ✓ | | ✓ | ✓ | | |
|
||||
| JSON parse abuse | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | | |
|
||||
| IDOR | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | | |
|
||||
| Data exfiltration | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | | |
|
||||
|
||||
`ENV_VAR`, `SHELL_ESCAPE`, and `URL_ENCODE` are source and sanitizer caps with
|
||||
no externally observable sink behavior. They route to
|
||||
`Unsupported(SoundOracleUnavailable)` rather than counting as a missing-payload
|
||||
gap.
|
||||
|
||||
## Framework adapters
|
||||
|
||||
Adapters bind a function to its external entry surface so the harness can
|
||||
drive the real entry point (an HTTP request through the framework, a published
|
||||
message, a scheduled fire) instead of calling the function in isolation.
|
||||
Middleware and request validation participate in the verdict that way.
|
||||
|
||||
| Language | HTTP routers | Other surfaces |
|
||||
| --- | --- | --- |
|
||||
| Python | Flask, Django, FastAPI, Starlette | Jinja2, pickle, LDAP, Celery, Kafka, SQS, Pub/Sub, RabbitMQ, Django Channels, Socket.IO, Django middleware, Django + Flask migrations |
|
||||
| JavaScript | Express, Koa, NestJS, Fastify | Handlebars, Apollo + Relay GraphQL, lodash.merge + JSON deep-assign, Socket.IO, SQS, Express middleware, Knex + Prisma + Sequelize migrations |
|
||||
| TypeScript | NestJS | Object.assign + lodash.merge + JSON deep-assign |
|
||||
| Java | Spring, Quarkus, Micronaut, Jakarta Servlet | Thymeleaf, ObjectInputStream, Spring LDAP, Kafka, SQS, RabbitMQ, Quartz, Spring middleware, Flyway + Liquibase migrations |
|
||||
| PHP | Laravel, Symfony, CodeIgniter | Twig, unserialize, LDAP, Laravel middleware, Laravel migrations |
|
||||
| Ruby | Rails, Sinatra, Hanami | ERB, Marshal, Sidekiq, ActionCable, Rails middleware, Rails migrations |
|
||||
| Go | Gin, Echo, Fiber, Chi | gqlgen GraphQL, NATS, Pub/Sub, go-migrate migrations |
|
||||
| Rust | Axum, Actix, Rocket, Warp | Juniper GraphQL, Refinery + SQLx migrations |
|
||||
| C / C++ | none | argv / stdin entry only |
|
||||
|
||||
Adapters are sanitizer-aware. An XXE, header-injection, open-redirect, SSTI,
|
||||
LDAP, XPath, deserialization, crypto, or data-exfil adapter declines the
|
||||
binding when the surrounding source visibly hardens the call: a parser set to
|
||||
`disallow-doctype-decl` or `resolve_entities=False`, a value routed through
|
||||
`LdapEncoder.filterEncode` or `escape_filter_chars`, a weak primitive swapped
|
||||
for `secrets.token_bytes` or `crypto.randomBytes` or `SecureRandom`, or a
|
||||
redirect host checked against an allowlist. That cuts adapter false positives
|
||||
without losing the genuinely dangerous calls.
|
||||
|
||||
## Entry points
|
||||
|
||||
The verifier knows how to stand up these entry shapes:
|
||||
|
||||
`Function`, `HttpRoute`, `CliSubcommand`, `LibraryApi`, `ClassMethod`,
|
||||
`MessageHandler`, `ScheduledJob`, `GraphQLResolver`, `WebSocket`,
|
||||
`Middleware`, `Migration`.
|
||||
|
||||
`ClassMethod` walks constructor parameters and builds the receiver, preferring
|
||||
a default constructor and otherwise stubbing dependencies (`MockHttpClient`,
|
||||
`MockDatabaseConnection`, `MockLogger`) up to a bounded depth. `MessageHandler`
|
||||
boots an in-sandbox broker stub on loopback and publishes the payload.
|
||||
`Migration` runs under a database-in-test-mode profile with no real
|
||||
connection. An entry kind a language emitter does not yet support produces
|
||||
`Inconclusive(EntryKindUnsupported)` with a hint, never a silent skip.
|
||||
|
||||
## Sandbox backends
|
||||
|
||||
```bash
|
||||
nyx scan --backend auto # docker when available, else process (default)
|
||||
nyx scan --backend docker # require docker
|
||||
nyx scan --backend process # run on the host with weaker isolation
|
||||
nyx scan --unsafe-sandbox # alias for --backend process
|
||||
nyx scan --harden strict # full process-backend lockdown
|
||||
```
|
||||
|
||||
Docker is the preferred backend. It mounts only the entry file's directory and
|
||||
blocks outbound network by default. Nyx binds a loopback OOB listener at scan
|
||||
start for callback-style payloads (SSRF, blind SSTI). When the bind succeeds,
|
||||
Docker switches to bridge networking with a host-gateway route so the harness
|
||||
can reach the listener; OOB payloads are skipped if the bind fails.
|
||||
|
||||
The process backend runs on the host. It is useful for development and
|
||||
machines without Docker, and it does not provide the same isolation. Hardening
|
||||
profiles apply to it:
|
||||
|
||||
- `standard` (default): no-new-privs plus a memory rlimit on Linux. No
|
||||
`sandbox-exec` wrap on macOS.
|
||||
- `strict`: namespace unshare, chroot to the workdir, and a default-deny
|
||||
seccomp filter on Linux; `sandbox-exec -f <cap>.sb` on macOS. Opt-in,
|
||||
because interpreted Linux harnesses can SIGSYS until the per-language seccomp
|
||||
allowlists are widened.
|
||||
|
||||
Every sink under test passes through the policy deny rules in
|
||||
`src/dynamic/policy.rs` before the harness builds. Network egress, writes
|
||||
outside the sandbox root, and process spawns can be denied per rule, and the
|
||||
deny decision lands in the trace.
|
||||
|
||||
## Performance
|
||||
|
||||
Verification adds a harness build and a sandbox run per finding. Two pieces of
|
||||
infrastructure keep that affordable at corpus scale.
|
||||
|
||||
Per-language build pools reuse a warm toolchain across findings instead of
|
||||
cold-starting one each time. Java runs a long-lived `javac` daemon; Node, PHP,
|
||||
Ruby, Go, Rust, C, and C++ reuse shared module, package, and object caches;
|
||||
Python layers a read-only venv with a warmed bytecode cache. The target is a
|
||||
P50 harness build at or under 200ms hot and 1.5s cold, with an OWASP-scale run
|
||||
finishing in 10 minutes on the dev reference machine.
|
||||
|
||||
Copy-on-write workdirs (`clonefile` on macOS, `reflink` or `copy_file_range`
|
||||
on Linux) replace per-finding file copies, and the worker pool routes findings
|
||||
into per-cap concurrency lanes so a slow `DESERIALIZE` harness does not block
|
||||
fast `SSRF` ones.
|
||||
|
||||
The CI ship gate holds the with-verify to static-only wall-clock ratio at or
|
||||
under 1.5x on `benches/fixtures/`. If a change pushes it past that, the gate
|
||||
fails.
|
||||
|
||||
## Repro artifacts
|
||||
|
||||
Confirmed findings write a hermetic bundle:
|
||||
|
||||
```text
|
||||
~/.cache/nyx/dynamic/repro/<spec_hash>/
|
||||
```
|
||||
|
||||
The bundle carries the harness spec, payload, expected output, trace, and a
|
||||
`reproduce.sh`. When the toolchain is pinned in `tools/image-builder/images.toml`
|
||||
it also writes a `docker_pull.sh`.
|
||||
|
||||
```bash
|
||||
cd ~/.cache/nyx/dynamic/repro/<spec_hash>
|
||||
./reproduce.sh
|
||||
./reproduce.sh --docker
|
||||
```
|
||||
|
||||
Use the Docker form when the bundle records a pinned image or when host
|
||||
toolchains differ from the original run.
|
||||
|
||||
## Configuration
|
||||
|
||||
```toml
|
||||
[scanner]
|
||||
verify = true # run dynamic verification after static analysis
|
||||
verify_all_confidence = false # include findings below Confidence::Medium
|
||||
verify_backend = "auto" # auto | docker | process | firecracker
|
||||
harden_profile = "standard" # standard | strict
|
||||
```
|
||||
|
||||
Set `verify = false` to make scans static-only unless the command line
|
||||
overrides it. See [Configuration](configuration.md) for the full table.
|
||||
|
||||
## Event log
|
||||
|
||||
Nyx writes verdict events to:
|
||||
|
||||
```text
|
||||
~/.cache/nyx/dynamic/events.jsonl
|
||||
```
|
||||
|
||||
Each line is a JSON object with a versioned envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"nyx_version": "0.8.0",
|
||||
"corpus_version": "15",
|
||||
"kind": "verdict",
|
||||
"ts": "2026-06-01T18:42:09Z",
|
||||
"finding_id": "a3b1...",
|
||||
"spec_hash": "9f4e...",
|
||||
"lang": "python",
|
||||
"cap": "SQL_QUERY",
|
||||
"status": "Confirmed",
|
||||
"toolchain_id": "python-3.11",
|
||||
"toolchain_match": "exact",
|
||||
"duration_ms": 312,
|
||||
"build_attempts": 1
|
||||
}
|
||||
```
|
||||
|
||||
The literal `nyx_version` and `corpus_version` values shift between releases;
|
||||
see `crate::dynamic::telemetry::CORPUS_VERSION` for the active payload-corpus
|
||||
version your binary writes.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `schema_version` | Event schema version. Readers reject mismatches. |
|
||||
| `nyx_version` | Version of the Nyx binary that wrote the event. |
|
||||
| `corpus_version` | Payload corpus version used for the verdict. |
|
||||
| `kind` | `verdict` or `rank_delta`. Feedback rows use an `event: "verify_feedback"` field instead. |
|
||||
| `ts` | Write time in RFC 3339 format. |
|
||||
| `finding_id` | Stable finding identifier. |
|
||||
| `spec_hash` | Hash of the harness spec. |
|
||||
| `lang` | Language slug, or `unknown` when spec derivation failed. |
|
||||
| `cap` | Sink capability, such as `SQL_QUERY` or `CODE_EXEC`. |
|
||||
| `status` | `Confirmed`, `PartiallyConfirmed`, `NotConfirmed`, `Inconclusive`, or `Unsupported`. |
|
||||
| `inconclusive_reason` | Present when `status` is `Inconclusive`. |
|
||||
|
||||
If the schema changes, move or delete the old `events.jsonl` before reading it
|
||||
with the new binary. Programmatic readers should use
|
||||
`crate::dynamic::telemetry::read_events(path)`.
|
||||
|
||||
### Sampling
|
||||
|
||||
`[telemetry]` in `nyx.toml` controls event retention:
|
||||
|
||||
```toml
|
||||
[telemetry]
|
||||
keep_all_confirmed = true
|
||||
keep_all_inconclusive = true
|
||||
sample_rate_other = 1.0
|
||||
```
|
||||
|
||||
`sample_rate_other` accepts `0.0` to `1.0` and applies to `NotConfirmed` and
|
||||
`Unsupported` verdicts. The decision is deterministic for a given `spec_hash`.
|
||||
Confirmed, Inconclusive, and rank-delta events are always kept by default.
|
||||
|
||||
Set `NYX_NO_TELEMETRY=1` to disable event writes.
|
||||
|
||||
## Feedback
|
||||
|
||||
To record a bad verdict:
|
||||
|
||||
```bash
|
||||
nyx verify-feedback <finding_id> --wrong "reason"
|
||||
```
|
||||
|
||||
Feedback is written to the local event log. Nyx does not upload it.
|
||||
|
||||
## Determinism
|
||||
|
||||
Every random source is seeded from the spec hash, so two runs of the same spec
|
||||
produce identical payloads and identical verdicts. `scripts/check_no_unseeded_rand.sh`
|
||||
audits the tree for unseeded `rand` usage on every CI run.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The harness drives the finding's enclosing entry function when one is
|
||||
derivable, routing the payload to the tainted parameter, so a guard in the
|
||||
code around the sink (a merge target built with `Object.create(null)`, an
|
||||
`ObjectInputStream` subclass whose `resolveClass` enforces an allowlist, a
|
||||
const-name check before `Marshal.load`) runs first and participates in the
|
||||
verdict. The build-time choice is recorded on the verify trace as
|
||||
`entry_invocation` (`mode=entry_function` or `mode=direct_sink`). When no
|
||||
enclosing entry can be derived the harness falls back to driving the sink
|
||||
directly, and that fallback can over-confirm a guard it never executes. Read
|
||||
a `direct_sink` `Confirmed` as "this sink is reachable and fires on attacker
|
||||
input," not "this exact code path has no in-line mitigation." Framework-level
|
||||
guards (auth middleware, helmet) are also recognized and demote to
|
||||
`ConfirmedWithKnownGuard`.
|
||||
- Per-language payload curation is uneven. Command and code injection ship for
|
||||
all ten languages; the classic data-style injection caps (SQL, path
|
||||
traversal, SSRF, XSS) ship a tuned set for Rust and fall back to a
|
||||
language-neutral payload union elsewhere; the framework-specific caps are
|
||||
curated for the languages where they occur. The matrix above is the precise
|
||||
state.
|
||||
- A `NotConfirmed` verdict is not a clean bill. It means the harness did not
|
||||
fire, which can be an infeasible path or a corpus that does not cover the
|
||||
shape. Keep reviewing `NotConfirmed` findings.
|
||||
- The process backend is weaker isolation than Docker. Use `--backend docker`
|
||||
or `--harden strict` for untrusted code, and never `--unsafe-sandbox` in CI.
|
||||
- Real-corpus acceptance rows (OWASP Benchmark, NodeGoat, Juice Shop, and the
|
||||
polyglot set) self-skip in CI unless the corresponding `NYX_*_CORPUS`
|
||||
environment variable points at a checkout. They are not vendored into the
|
||||
repo.
|
||||
- C and C++ have no framework adapters. Findings in those languages verify
|
||||
through `argv` and `stdin` entry points only.
|
||||
|
||||
## Browser UI
|
||||
|
||||
`nyx serve` shows dynamic verdicts on finding detail pages, uses them in
|
||||
ranking, and can compare verdict changes between saved scans. See
|
||||
[Output formats](output.md) for the `dynamic_verdict` schema.
|
||||
|
|
@ -6,6 +6,23 @@ If you're going to act on a finding, it helps to know how the scanner got there.
|
|||
|
||||
A scan runs in two passes over the file tree, with an optional SQLite index that lets the second scan skip files whose content hash hasn't changed.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Walk["Walk file tree"] --> Pass1["Pass 1 per file<br/>tree-sitter parse, CFG, SSA"]
|
||||
Pass1 --> Summaries["Per-function summaries<br/>sources, sinks, sanitizers, returns, points-to"]
|
||||
Pass1 --> Hierarchy["Type hierarchy index<br/>extends, implements, impl-for, includes"]
|
||||
Summaries --> Global["GlobalSummaries map<br/>plus optional SQLite cache"]
|
||||
Hierarchy --> Global
|
||||
Global --> Pass2["Pass 2 per file<br/>cross-file context"]
|
||||
Pass2 --> Taint["Forward SSA taint worklist<br/>finite lattice, guaranteed convergence"]
|
||||
Pass2 --> Calls["Call precision<br/>k=1 inline, summaries, SCC fixed-point"]
|
||||
Taint --> Findings["Findings with evidence<br/>source, path, sink, engine notes"]
|
||||
Calls --> Findings
|
||||
Findings --> Rank["Rank and dedupe<br/>severity, confidence, score"]
|
||||
Rank --> Verify["Dynamic verification<br/>sandboxed harnesses, verdicts"]
|
||||
Verify --> Emit["Emit<br/>console, JSON, SARIF, UI"]
|
||||
```
|
||||
|
||||
**Pass 1, per file.** Tree-sitter parses the file. Nyx builds an intra-procedural control-flow graph, lowers it to SSA, and extracts a summary per function describing what that function does at the boundary: which arguments flow to sinks, which sources it reads from, which sinks it calls, what taint it strips, what it returns. Summaries are persisted to SQLite ([`src/summary/`](https://github.com/elicpeter/nyx/tree/master/src/summary/), [`src/database.rs`](https://github.com/elicpeter/nyx/blob/master/src/database.rs)).
|
||||
|
||||
**Summary merge.** All per-file summaries get unioned into a global map keyed by qualified function name.
|
||||
|
|
@ -18,6 +35,8 @@ When a method call has a receiver typed as a super-class, trait, or interface, *
|
|||
|
||||
A separate **field-sensitive points-to** pass tracks abstract locations down to the field level, so `c.mu.Lock()` is a lock on `Field(c, mu)` rather than on `c` as a whole. That distinction is what lets the resource-lifecycle and taint passes tell `obj.field = tainted; sink(obj.other_field)` apart from the conservative whole-variable approximation. Subscript reads and writes (`arr[i]`, `map[k] = v`) lower to synthetic `__index_get__` / `__index_set__` calls so the same container model handles them. Set `NYX_POINTER_ANALYSIS=0` to fall back to the pre-pointer-pass behaviour for baseline comparison.
|
||||
|
||||
**Dynamic verification.** After ranking and dedupe, default builds verify Medium and High confidence findings unless `--no-verify` or `scanner.verify = false` is set. The verifier derives a small harness from the finding, runs it in a sandbox against curated payloads, and stores the result on `evidence.dynamic_verdict`. `Confirmed` means a vulnerable payload fired and its benign control stayed clean. `NotConfirmed` means the harness ran but did not fire, not that the finding is closed.
|
||||
|
||||
## Optional analyses on top
|
||||
|
||||
These run on top of the forward taint pass. They're independently switchable via `[analysis.engine]` config or matching CLI flags. See [advanced-analysis.md](advanced-analysis.md) for the full description and tradeoffs.
|
||||
|
|
@ -47,6 +66,6 @@ Findings whose engine notes indicate a bound was hit can be filtered with `--req
|
|||
|
||||
## What you get out
|
||||
|
||||
Each finding carries the source location, the sink location, the path in between (when symex produced one), the rule ID, severity, attack-surface score, confidence level, and a list of engine notes describing any precision loss along the way. Console output is human-readable; JSON and SARIF carry the full evidence object for tooling.
|
||||
Each finding carries the source location, the sink location, the path in between (when symex produced one), the rule ID, severity, attack-surface score, confidence level, dynamic verdict when one was attempted, and a list of engine notes describing any precision loss along the way. Console output is human-readable; JSON and SARIF carry the full evidence object for tooling.
|
||||
|
||||
For the JSON shape and SARIF mapping, see [output.md](output.md).
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ The classifications here are grounded in three concrete signals:
|
|||
1. **Rule depth**: how many distinct source / sanitizer / sink matchers exist
|
||||
for the language in `src/labels/<lang>.rs`, and how many vulnerability
|
||||
classes (Cap bits) those matchers cover.
|
||||
2. **Benchmark results**: rule-level precision / recall / F1 on the 492-case
|
||||
2. **Benchmark results**: rule-level precision / recall / F1 on the synthetic
|
||||
corpus in
|
||||
[`tests/benchmark/RESULTS.md`](https://github.com/elicpeter/nyx/blob/master/tests/benchmark/RESULTS.md).
|
||||
`RESULTS.md` is the authoritative case counts and per-language scores.
|
||||
3. **Known weak spots**: FPs and FNs the maintainers have deliberately left
|
||||
in the benchmark rather than suppressed, plus structural engine
|
||||
limitations the corpus does not stress, documented in
|
||||
|
|
@ -42,23 +43,25 @@ use tree-sitter and are stable; parsing is not a differentiator.
|
|||
|
||||
### Stable tier
|
||||
|
||||
#### Python: 100% P / 100% R / 100% F1 *(46-case corpus)*
|
||||
#### Python
|
||||
|
||||
- **Rule depth**: 5 source families, 7 sanitizer families, 21 sink matchers
|
||||
- **Rule depth**: deep source / sanitizer / sink coverage in
|
||||
[`src/labels/python.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/python.rs)
|
||||
spanning HTML, URL, Shell, SQL, Code, SSRF, File I/O, and Deserialization.
|
||||
- **Framework context**: Flask, Django, argparse source matchers; `flask_request`
|
||||
import-alias support.
|
||||
- **Advanced analysis**: gated sinks (`Popen`, `subprocess.run/call` with
|
||||
activation-arg awareness), most SSA-equivalence and symbolic-execution
|
||||
fixtures target Python.
|
||||
- **Fixtures**: 125 under `tests/fixtures/` plus 42 benchmark cases.
|
||||
- **Fixtures**: extensive `.py` coverage under `tests/fixtures/` plus the benchmark cases.
|
||||
- **Blind spots**: f-string interpolation is not explicitly modeled as a
|
||||
distinct taint-producing construct; string-formatting flows are caught by
|
||||
the general concatenation path.
|
||||
|
||||
#### JavaScript: 100% P / 100% R / 100% F1 *(42-case corpus)*
|
||||
#### JavaScript
|
||||
|
||||
- **Rule depth**: 3 source families, 10 sanitizer families, 24 sink matchers
|
||||
- **Rule depth**: deep source / sanitizer / sink coverage in
|
||||
[`src/labels/javascript.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/javascript.rs)
|
||||
spanning HTML, URL, JSON, Shell, SQL, Code, SSRF, and File I/O.
|
||||
- **Advanced analysis**: gated sinks (`setAttribute`, `parseFromString`),
|
||||
two-level SSA solve for top-level + per-function scopes
|
||||
|
|
@ -66,15 +69,16 @@ use tree-sitter and are stable; parsing is not a differentiator.
|
|||
StringFact, abstract-interpretation interval tracking.
|
||||
- **Framework context**: Express, Koa, Fastify (via in-file import scan when
|
||||
`package.json` is absent).
|
||||
- **Fixtures**: 238 under `tests/fixtures/`; the largest fixture set of any
|
||||
- **Fixtures**: the largest `.js` set under `tests/fixtures/` of any
|
||||
language.
|
||||
- **Blind spots**: template literals are lowered through concatenation rather
|
||||
than modeled as a first-class taint operator; dynamic property access
|
||||
(`obj[user]`) is conservatively treated.
|
||||
|
||||
#### TypeScript: 100% P / 100% R / 100% F1 *(47-case corpus)*
|
||||
#### TypeScript
|
||||
|
||||
- **Rule depth**: Shares the JS ruleset (3 sources, 10 sanitizers, 24 sinks)
|
||||
- **Rule depth**: shares the JS ruleset (see
|
||||
[`src/labels/typescript.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/typescript.rs))
|
||||
plus TS-specific grammar handling.
|
||||
- **Advanced analysis**: TSX and JSX grammars wired;
|
||||
discriminated-union narrowing, generic erasure, decorator flow, and
|
||||
|
|
@ -82,15 +86,16 @@ use tree-sitter and are stable; parsing is not a differentiator.
|
|||
stressors.
|
||||
- **Framework context**: Fastify detection via `detect_in_file_frameworks`
|
||||
(import-driven, no `package.json` required).
|
||||
- **Fixtures**: 39 test fixtures plus 42 benchmark cases.
|
||||
- **Fixtures**: dedicated `.ts` / `.tsx` set under `tests/fixtures/` plus the benchmark cases.
|
||||
- **Blind spots**: `as any` casts and `any`-typed flows are handled
|
||||
conservatively (treated as tainted).
|
||||
|
||||
### Beta tier
|
||||
|
||||
#### Go: 100% P / 100% R / 100% F1 *(56-case corpus)*
|
||||
#### Go
|
||||
|
||||
- **Rule depth**: 4 source families, 4 sanitizer families, 9 sink matchers
|
||||
- **Rule depth**: mid-depth source / sanitizer / sink coverage in
|
||||
[`src/labels/go.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/go.rs)
|
||||
covering HTML, URL, Shell, SQL, SSRF, Crypto, and File I/O.
|
||||
- **Framework context**: Gin, Echo source matchers.
|
||||
- **Recent fix**: `strings.ReplaceAll` is now recognised as a CMDi sanitiser
|
||||
|
|
@ -103,9 +108,10 @@ use tree-sitter and are stable; parsing is not a differentiator.
|
|||
so production CI gates may surface additional FPs the corpus does not
|
||||
exercise.
|
||||
|
||||
#### Java: 100% P / 100% R / 100% F1 *(35-case corpus)*
|
||||
#### Java
|
||||
|
||||
- **Rule depth**: 3 source families, 8 sanitizer families, 10 sink matchers
|
||||
- **Rule depth**: mid-depth source / sanitizer / sink coverage in
|
||||
[`src/labels/java.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/java.rs)
|
||||
covering HTML, URL, Shell, SQL, Code, SSRF, and Deserialization.
|
||||
- **Framework context**: Spring, JPA, Hibernate ORM rules; JNDI injection
|
||||
sinks.
|
||||
|
|
@ -115,18 +121,20 @@ use tree-sitter and are stable; parsing is not a differentiator.
|
|||
cannot be inferred are conservatively over-tainted on unusual builder
|
||||
chains.
|
||||
|
||||
#### PHP: 100% P / 100% R / 100% F1 *(37-case corpus)*
|
||||
#### PHP
|
||||
|
||||
- **Rule depth**: 3 source families (`$_GET`, `$_POST`, `$_REQUEST`
|
||||
superglobals), 7 sanitizer families, 10 sink matchers covering HTML, URL,
|
||||
Shell, SQL, Code, SSRF, File I/O, and Deserialization.
|
||||
- **Rule depth**: sources include `$_GET`, `$_POST`, `$_REQUEST`
|
||||
superglobals plus sanitizer / sink matchers in
|
||||
[`src/labels/php.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/php.rs)
|
||||
covering HTML, URL, Shell, SQL, Code, SSRF, File I/O, and Deserialization.
|
||||
- **Known gaps**: no gated sinks. Limited framework context (Laravel raw
|
||||
methods only). `echo` language-construct detection is wired but its
|
||||
inner-argument propagation is narrower than function-call sinks.
|
||||
|
||||
#### Ruby: 100% P / 100% R / 100% F1 *(39-case corpus)*
|
||||
#### Ruby
|
||||
|
||||
- **Rule depth**: 3 source families, 7 sanitizer families, 16 sink matchers
|
||||
- **Rule depth**: source / sanitizer / sink coverage in
|
||||
[`src/labels/ruby.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/ruby.rs)
|
||||
covering HTML, Shell, SQL, Code, SSRF, File I/O, and Deserialization. SSRF
|
||||
coverage includes `URI.open` and the low-level `OpenURI.open_uri` it
|
||||
delegates to (the canonical CarrierWave CVE-2021-21288 sink).
|
||||
|
|
@ -138,21 +146,21 @@ use tree-sitter and are stable; parsing is not a differentiator.
|
|||
- **Framework context**: Rails helpers (`sanitize_sql`, `permit`, `require`).
|
||||
- **Known gaps**: string interpolation inside shell and SQL strings is
|
||||
recognized structurally but not modeled as a distinct operator.
|
||||
`begin/rescue/ensure` exception-edge wiring is documented as deferred
|
||||
(structurally incompatible with `build_try()`).
|
||||
`begin/rescue/ensure` exception-edge wiring is not implemented.
|
||||
|
||||
#### Rust: 100% P / 100% R / 100% F1 *(70-case adversarial corpus)*
|
||||
#### Rust
|
||||
|
||||
Rust holds the largest per-language adversarial corpus. PathFact-driven
|
||||
path-domain narrowing covers the `rs-safe-*` regression set.
|
||||
|
||||
- **Rule depth**: 6 source families, **2** sanitizer families (prefix and
|
||||
type-coercion), 11 sink matchers covering HTML, Shell, SQL, SSRF,
|
||||
Deserialization, and File I/O. Extensive framework source coverage
|
||||
(Axum, Actix, Rocket); the most of any language on the source side. The
|
||||
narrow sanitizer count is the primary reason Rust is not in the Stable
|
||||
tier. Engine-side path/typed sanitizer recognition (PathFact) compensates,
|
||||
but the ruleset itself is shallow.
|
||||
- **Rule depth**: source / sanitizer / sink coverage in
|
||||
[`src/labels/rust.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/rust.rs)
|
||||
covering HTML, Shell, SQL, SSRF, Deserialization, and File I/O.
|
||||
Extensive framework source coverage (Axum, Actix, Rocket); the most of
|
||||
any language on the source side. The narrow sanitizer rule set (prefix
|
||||
and type-coercion only) is the primary reason Rust is not in the Stable
|
||||
tier. Engine-side path/typed sanitizer recognition (PathFact)
|
||||
compensates, but the ruleset itself is shallow.
|
||||
- **Coverage**: SQL class (`rusqlite`, `sqlx`, `diesel`, `postgres`),
|
||||
Deserialization class (`serde_yaml`, `bincode`, `rmp_serde`, `ciborium`,
|
||||
`ron`, `toml`), file I/O (`fs::remove_file/dir/rename/copy`), and the
|
||||
|
|
@ -221,20 +229,22 @@ Clang Static Analyzer, or Infer for production use.
|
|||
doesn't make `buf` an alias for every element.
|
||||
- Nested classes beyond one level (C++ only).
|
||||
|
||||
#### C: 100% P / 100% R / 100% F1 *(30-case corpus)*
|
||||
#### C
|
||||
|
||||
- **Rule depth**: 3 source families, **2** sanitizer families (the
|
||||
`sanitize_*` prefix and numeric-parse functions), 5 sink matchers spanning
|
||||
Shell, File, SSRF, and Format-String.
|
||||
- **Rule depth**: source / sanitizer / sink coverage in
|
||||
[`src/labels/c.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/c.rs).
|
||||
Sanitizers are limited to the `sanitize_*` prefix and numeric-parse
|
||||
functions; sinks span Shell, File, SSRF, and Format-String.
|
||||
- **Known gaps**: no framework rules, no gated sinks. The structural
|
||||
limitations listed above are the dominant concern; rule additions alone
|
||||
will not lift this language out of the Preview tier.
|
||||
|
||||
#### C++: 100% P / 100% R / 100% F1 *(33-case corpus, plus 6 new fixtures for STL / builder / inline-method flows)*
|
||||
#### C++
|
||||
|
||||
- **Rule depth**: Builds on the C ruleset with `std::cin` / `std::getline`
|
||||
sources and a wider numeric-sanitizer set covering the full `std::sto*`
|
||||
family (3 sources, 3 sanitizer families, 5 sinks).
|
||||
- **Rule depth**: builds on the C ruleset (see
|
||||
[`src/labels/cpp.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/cpp.rs))
|
||||
with `std::cin` / `std::getline` sources and a wider numeric-sanitizer
|
||||
set covering the full `std::sto*` family.
|
||||
- **Known gaps**: still no framework rules and no gated sinks. The
|
||||
structural blind spots are now narrower than they were a release ago
|
||||
(see "What now works" above), but function pointers and the harder
|
||||
|
|
|
|||
69
docs/mermaid-init.js
Normal file
69
docs/mermaid-init.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
(function () {
|
||||
const MERMAID_URL =
|
||||
"https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.esm.min.mjs";
|
||||
|
||||
async function renderMermaid() {
|
||||
const blocks = Array.from(
|
||||
document.querySelectorAll("pre > code.language-mermaid"),
|
||||
);
|
||||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mermaidModule = await import(MERMAID_URL);
|
||||
const mermaid = mermaidModule.default;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: {
|
||||
background: "transparent",
|
||||
fontFamily:
|
||||
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
|
||||
primaryColor: "#0f172a",
|
||||
primaryTextColor: "#e5e7eb",
|
||||
primaryBorderColor: "#22d3ee",
|
||||
secondaryColor: "#134e4a",
|
||||
secondaryTextColor: "#e5e7eb",
|
||||
secondaryBorderColor: "#2dd4bf",
|
||||
tertiaryColor: "#1e293b",
|
||||
tertiaryTextColor: "#e5e7eb",
|
||||
tertiaryBorderColor: "#64748b",
|
||||
lineColor: "#94a3b8",
|
||||
edgeLabelBackground: "#0f172a",
|
||||
clusterBkg: "#111827",
|
||||
clusterBorder: "#475569",
|
||||
},
|
||||
});
|
||||
|
||||
for (const block of blocks) {
|
||||
const pre = block.parentElement;
|
||||
if (!pre) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "nyx-mermaid";
|
||||
|
||||
const diagram = document.createElement("div");
|
||||
diagram.className = "mermaid";
|
||||
diagram.textContent = block.textContent.trim();
|
||||
|
||||
wrapper.appendChild(diagram);
|
||||
pre.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
await mermaid.run({ querySelector: ".nyx-mermaid .mermaid" });
|
||||
} catch (error) {
|
||||
console.warn("Mermaid rendering failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", renderMermaid);
|
||||
} else {
|
||||
renderMermaid();
|
||||
}
|
||||
})();
|
||||
15
docs/mermaid.css
Normal file
15
docs/mermaid.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.nyx-mermaid {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.nyx-mermaid svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
175
docs/output.md
175
docs/output.md
|
|
@ -19,9 +19,9 @@ Human-readable, color-coded output to stdout. Status messages go to stderr.
|
|||
|
||||
| Tag | Color | Meaning |
|
||||
|-----|-------|---------|
|
||||
| `[HIGH]` | Red, bold | Critical -- likely exploitable |
|
||||
| `[MEDIUM]` | Orange, bold | Important -- may be exploitable |
|
||||
| `[LOW]` | Muted blue-gray | Informational -- code quality or weak signal |
|
||||
| `[HIGH]` | Red, bold | Critical, likely exploitable |
|
||||
| `[MEDIUM]` | Orange, bold | Important, may be exploitable |
|
||||
| `[LOW]` | Muted blue-gray | Informational: code quality or weak signal |
|
||||
|
||||
### Evidence fields
|
||||
|
||||
|
|
@ -69,48 +69,71 @@ Use --include-quality, --max-low, or --all to adjust.
|
|||
|
||||
## JSON
|
||||
|
||||
Machine-readable JSON array. Each finding is an object:
|
||||
Machine-readable JSON object. The main keys are:
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `findings` | array | Finding objects |
|
||||
| `chains` | array | Composed exploit chains, when emitted |
|
||||
| `dynamic_verification` | object | Count of attached dynamic verdicts |
|
||||
| `verdict_diff` | object | Baseline comparison, only when `--baseline` is used |
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"path": "src/handler.rs",
|
||||
"line": 12,
|
||||
"col": 5,
|
||||
"severity": "High",
|
||||
"id": "taint-unsanitised-flow (source 5:11)",
|
||||
"path_validated": false,
|
||||
"labels": [
|
||||
["Source", "env::var(\"CMD\") at 5:11"],
|
||||
["Sink", "Command::new(\"sh\").arg(\"-c\")"]
|
||||
],
|
||||
"confidence": "High",
|
||||
"evidence": {
|
||||
"source": {
|
||||
"path": "src/handler.rs",
|
||||
"line": 5,
|
||||
"col": 11,
|
||||
"kind": "source",
|
||||
"snippet": "env::var(\"CMD\")"
|
||||
{
|
||||
"findings": [
|
||||
{
|
||||
"path": "src/handler.rs",
|
||||
"line": 12,
|
||||
"col": 5,
|
||||
"severity": "High",
|
||||
"id": "taint-unsanitised-flow (source 5:11)",
|
||||
"path_validated": false,
|
||||
"labels": [
|
||||
["Source", "env::var(\"CMD\") at 5:11"],
|
||||
["Sink", "Command::new(\"sh\").arg(\"-c\")"]
|
||||
],
|
||||
"confidence": "High",
|
||||
"evidence": {
|
||||
"source": {
|
||||
"path": "src/handler.rs",
|
||||
"line": 5,
|
||||
"col": 11,
|
||||
"kind": "source",
|
||||
"snippet": "env::var(\"CMD\")"
|
||||
},
|
||||
"sink": {
|
||||
"path": "src/handler.rs",
|
||||
"line": 12,
|
||||
"col": 5,
|
||||
"kind": "sink",
|
||||
"snippet": "Command::new(\"sh\")"
|
||||
},
|
||||
"notes": ["source_kind:EnvironmentConfig"],
|
||||
"dynamic_verdict": {
|
||||
"finding_id": "a3b12f0c91e04420",
|
||||
"status": "Confirmed",
|
||||
"triggered_payload": "cmdi-echo-marker"
|
||||
}
|
||||
},
|
||||
"sink": {
|
||||
"path": "src/handler.rs",
|
||||
"line": 12,
|
||||
"col": 5,
|
||||
"kind": "sink",
|
||||
"snippet": "Command::new(\"sh\")"
|
||||
},
|
||||
"notes": ["source_kind:EnvironmentConfig"]
|
||||
},
|
||||
"rank_score": 76.0,
|
||||
"rank_reason": [
|
||||
["severity_base", "60"],
|
||||
["analysis_kind", "10"],
|
||||
["source_kind", "5"],
|
||||
["evidence_count", "1"]
|
||||
]
|
||||
"rank_score": 76.0,
|
||||
"rank_reason": [
|
||||
["severity_base", "60"],
|
||||
["analysis_kind", "10"],
|
||||
["source_kind", "5"],
|
||||
["evidence_count", "1"]
|
||||
]
|
||||
}
|
||||
],
|
||||
"chains": [],
|
||||
"dynamic_verification": {
|
||||
"total": 1,
|
||||
"confirmed": 1,
|
||||
"partially_confirmed": 0,
|
||||
"not_confirmed": 0,
|
||||
"inconclusive": 0,
|
||||
"unsupported": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Field descriptions
|
||||
|
|
@ -132,6 +155,7 @@ Machine-readable JSON array. Each finding is an object:
|
|||
| `rank_score` | float | no | Attack-surface score (omitted when ranking disabled) |
|
||||
| `rank_reason` | array | no | Score breakdown (omitted when ranking disabled) |
|
||||
| `rollup` | object | no | Rollup data when findings are grouped (see below) |
|
||||
| `chain_member_of` | int | no | Stable hash of the emitted chain this finding belongs to |
|
||||
|
||||
Fields marked "no" are omitted when empty/null/false to keep output compact.
|
||||
|
||||
|
|
@ -139,9 +163,9 @@ Fields marked "no" are omitted when empty/null/false to keep output compact.
|
|||
|
||||
| Level | Meaning |
|
||||
|-------|---------|
|
||||
| `High` | Strong signal -- taint-confirmed flow, definite state violation |
|
||||
| `Medium` | Moderate signal -- resource leak, path-validated taint, CFG structural |
|
||||
| `Low` | Weak signal -- AST pattern match, possible resource leak, degraded analysis |
|
||||
| `High` | Strong signal: taint-confirmed flow, definite state violation |
|
||||
| `Medium` | Moderate signal: resource leak, path-validated taint, CFG structural |
|
||||
| `Low` | Weak signal: AST pattern match, possible resource leak, degraded analysis |
|
||||
|
||||
### Evidence object
|
||||
|
||||
|
|
@ -155,9 +179,40 @@ The `evidence` field provides structured provenance data:
|
|||
| `sanitizers` | array | Sanitizer spans |
|
||||
| `state` | object | State-machine evidence (machine, subject, from_state, to_state) |
|
||||
| `notes` | array | Free-form notes (e.g. `"source_kind:UserInput"`, `"path_validated"`) |
|
||||
| `dynamic_verdict` | object | Dynamic verification result, when verification ran or was skipped for a typed reason |
|
||||
|
||||
All fields are omitted when empty/null.
|
||||
|
||||
### Dynamic verdict object
|
||||
|
||||
`evidence.dynamic_verdict` uses this shape:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `finding_id` | string | Stable 16-character hex finding id |
|
||||
| `status` | string | `Confirmed`, `PartiallyConfirmed`, `NotConfirmed`, `Inconclusive`, or `Unsupported` |
|
||||
| `triggered_payload` | string | Payload label for `Confirmed` verdicts |
|
||||
| `reason` | object/string | Typed reason for `Unsupported` |
|
||||
| `inconclusive_reason` | object/string | Typed reason for `Inconclusive` |
|
||||
| `detail` | string | Extra build, sandbox, or policy detail |
|
||||
| `attempts` | array | Per-payload attempt summaries |
|
||||
| `toolchain_match` | string | `exact` or `drift` |
|
||||
| `differential` | object | Vulnerable versus benign control result, when both ran |
|
||||
| `hardening_outcome` | object | Process-backend hardening result, when recorded |
|
||||
|
||||
The top-level `dynamic_verification` object counts verdict statuses across the emitted findings:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 4,
|
||||
"confirmed": 2,
|
||||
"partially_confirmed": 0,
|
||||
"not_confirmed": 1,
|
||||
"inconclusive": 0,
|
||||
"unsupported": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Rollup object
|
||||
|
||||
When a finding is a rollup (grouped from multiple occurrences), the `rollup` field is present:
|
||||
|
|
@ -192,12 +247,13 @@ nyx scan . --format sarif > results.sarif
|
|||
|
||||
The SARIF output includes:
|
||||
|
||||
- **Tool metadata** -- Nyx name and version
|
||||
- **Rules** -- Rule ID, description, severity mapping
|
||||
- **Results** -- One result per finding with location, message, and properties
|
||||
- **Properties** -- Each result includes `category` and optionally `confidence` and `rollup.count`
|
||||
- **Related locations** -- Rollup findings include example locations in `relatedLocations`
|
||||
- **Artifacts** -- File paths referenced by findings
|
||||
- **Tool metadata**: Nyx name and version
|
||||
- **Rules**: Rule ID, description, severity mapping
|
||||
- **Results**: One result per finding with location, message, and properties
|
||||
- **Properties**: Each result includes `category` and optionally `confidence`, `rollup.count`, and `nyx_dynamic_verdict`
|
||||
- **Fingerprints**: Dynamic verdict status is added as `partialFingerprints.dynamic_verdict_status` when present
|
||||
- **Related locations**: Rollup findings include example locations in `relatedLocations`
|
||||
- **Artifacts**: File paths referenced by findings
|
||||
|
||||
### GitHub Code Scanning integration
|
||||
|
||||
|
|
@ -219,9 +275,10 @@ The SARIF output includes:
|
|||
|------|---------|
|
||||
| `0` | Scan completed successfully; no findings matched `--fail-on` threshold |
|
||||
| `1` | `--fail-on` threshold breached (at least one finding meets or exceeds the specified severity) |
|
||||
| Non-zero | Error (I/O, config, database, parse error) |
|
||||
| `2` | `--gate` policy tripped (e.g. `no-new-confirmed` saw a new Confirmed finding, or `resolve-all-confirmed` saw a previously Confirmed finding still open) |
|
||||
| Other non-zero | Error (I/O, config, database, parse error) |
|
||||
|
||||
Without `--fail-on`, Nyx always exits `0` on a successful scan regardless of findings count.
|
||||
Without `--fail-on` or `--gate`, Nyx always exits `0` on a successful scan regardless of findings count.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -229,9 +286,9 @@ Without `--fail-on`, Nyx always exits `0` on a successful scan regardless of fin
|
|||
|
||||
| Level | Description | Typical rules |
|
||||
|-------|-------------|---------------|
|
||||
| **High** | Critical vulnerabilities -- likely exploitable | Command injection, unsafe deserialization, banned C functions, taint-confirmed flows with user input sources |
|
||||
| **Medium** | Important issues -- may be exploitable with additional context | SQL concatenation, XSS sinks, reflection, unguarded sinks, resource leaks |
|
||||
| **Low** | Informational -- code quality or weak signals | Weak crypto algorithms, insecure randomness, `unwrap()`/`panic!()`, type-safety escapes |
|
||||
| **High** | Critical vulnerabilities, likely exploitable | Command injection, unsafe deserialization, banned C functions, taint-confirmed flows with user input sources |
|
||||
| **Medium** | Important issues, may be exploitable with additional context | SQL concatenation, XSS sinks, reflection, unguarded sinks, resource leaks |
|
||||
| **Low** | Informational: code quality or weak signals | Weak crypto algorithms, insecure randomness, `unwrap()`/`panic!()`, type-safety escapes |
|
||||
|
||||
### Non-production severity downgrade
|
||||
|
||||
|
|
@ -260,13 +317,13 @@ Suppress specific findings directly in source code using `nyx:ignore` comments.
|
|||
### Directive forms
|
||||
|
||||
```python
|
||||
x = dangerous() # nyx:ignore taint-unsanitised-flow ← suppresses this line
|
||||
x = dangerous() # nyx:ignore taint-unsanitised-flow (suppresses this line)
|
||||
# nyx:ignore-next-line taint-unsanitised-flow
|
||||
x = dangerous() ← suppresses this line
|
||||
x = dangerous() (suppressed by the comment above)
|
||||
```
|
||||
|
||||
- `nyx:ignore <RULE_ID>` -- suppresses findings on the **same line** as the comment.
|
||||
- `nyx:ignore-next-line <RULE_ID>` -- suppresses findings on the **next line**.
|
||||
- `nyx:ignore <RULE_ID>`: suppresses findings on the **same line** as the comment.
|
||||
- `nyx:ignore-next-line <RULE_ID>`: suppresses findings on the **next line**.
|
||||
- For taint findings, the primary line is the **sink line** (the `line` field in output).
|
||||
|
||||
### Rule ID matching
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ After `cargo install nyx-scanner` (or dropping a release binary on your PATH), p
|
|||
nyx scan ./my-project
|
||||
```
|
||||
|
||||
First run builds a SQLite index under `.nyx/`; later runs skip files whose content hash hasn't changed.
|
||||
First run builds a SQLite index under `.nyx/`; later runs skip files whose content hash hasn't changed. Default builds also verify Medium and High confidence findings in a sandbox. Use `--no-verify` when you want a static-only local loop.
|
||||
|
||||
## What a finding looks like
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ The same scan in console form:
|
|||
|
||||
Source: request.args.get (5:11)
|
||||
Sink: os.system
|
||||
[DYN: confirmed via cmdi-echo-marker-python]
|
||||
|
||||
6:5 ✖ [HIGH] py.cmdi.os_system (Score: 64, Confidence: High)
|
||||
os.system() runs a shell command
|
||||
|
|
@ -31,12 +32,15 @@ The same scan in console form:
|
|||
|
||||
Source: req.query.content (3:18)
|
||||
Sink: document.write
|
||||
[DYN: confirmed via xss-script-marker]
|
||||
|
||||
5:5 ⚠ [MEDIUM] js.xss.document_write (Score: 34, Confidence: High)
|
||||
document.write() is an XSS sink
|
||||
|
||||
Dynamic verification: 4 verdicts (2 confirmed, 0 partially confirmed, 1 not confirmed, 0 inconclusive, 1 unsupported)
|
||||
|
||||
warning 'demo' generated 10 issues.
|
||||
Finished in 0.054s.
|
||||
Finished in 1.842s.
|
||||
```
|
||||
|
||||
Each finding is one line of header plus evidence. Fields that matter:
|
||||
|
|
@ -48,6 +52,7 @@ Each finding is one line of header plus evidence. Fields that matter:
|
|||
| Score | Attack-surface ranking (severity + analysis kind + source kind + evidence). Higher is more exploitable |
|
||||
| Confidence | `High`, `Medium`, `Low`. Drops for AST-only matches, capped widened flows, and lowered-to-Low backwards-infeasible findings |
|
||||
| Source / Sink | Where tainted data entered and where the dangerous call happened |
|
||||
| `[DYN: ...]` | Dynamic verifier result, when Nyx built and ran a harness for the finding |
|
||||
|
||||
Two rules firing on the same line (the taint finding plus the AST pattern) is normal. The pattern matches the structural presence of `document.write`; the taint rule adds the evidence that `req.query.content` actually reached it. Both carry distinct rule IDs so suppressions can target one without the other.
|
||||
|
||||
|
|
@ -85,14 +90,17 @@ nyx scan . --require-converged
|
|||
|
||||
`--require-converged` keeps `under-report` findings (the emitted flow is still real) but drops over-reports and widenings. Intended for strict gates where a noisy finding is worse than nothing.
|
||||
|
||||
## Skip dataflow for a fast first pass
|
||||
## Skip work for a fast first pass
|
||||
|
||||
```bash
|
||||
nyx scan . --mode ast
|
||||
nyx scan . --no-verify
|
||||
```
|
||||
|
||||
AST-only mode runs tree-sitter patterns without building a CFG or running taint. It's fast and still catches banned-API uses, weak crypto, and obvious XSS sinks, but it can't tell `eval("1+1")` apart from `eval(userInput)`. Use it as a pre-commit filter, not as a CI gate replacement.
|
||||
|
||||
`--no-verify` keeps the static engine on but skips sandboxed execution. Use it when you are iterating locally and only need the analyzer result.
|
||||
|
||||
## Next
|
||||
|
||||
- [CLI reference](cli.md) for every flag and subcommand.
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
# Recall validation runbook
|
||||
|
||||
The recall-validation harness freezes a finding-shape baseline against
|
||||
real-world OSS targets so future engine work can prove "actually lifts
|
||||
recall on real code", not just "tests pass". This runbook covers
|
||||
re-running the validation against a fresh OSS release.
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Clone URL | Recall items exercised |
|
||||
|-------------------|--------------------------------------------|------------------------|
|
||||
| `cal_com` | https://github.com/calcom/cal.com | 1, 5, 6, 7 |
|
||||
| `vercel_commerce` | https://github.com/vercel/commerce | 1, 4, 7 |
|
||||
| `shadcn_examples` | https://github.com/shadcn-ui/ui | 4, 7 |
|
||||
| `blitz_apps` | https://github.com/blitz-js/blitz | 1, 3, 6 |
|
||||
|
||||
Item numbering is from `.pitboss/RECALL_GAPS.md`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|-----------------------------------------------|-----------------------------------------|
|
||||
| `scripts/validate_recall.sh` | runner (capture + diff modes) |
|
||||
| `tests/recall_targets/<target>.json` | per-target baseline |
|
||||
| `tests/recall_gaps.rs::validate_real_world_targets` | schema-validity test (`#[ignore]`)|
|
||||
| `tests/recall_gaps_baseline.json` | corpus regression baseline |
|
||||
|
||||
Baselines live next to the harness rather than under `.pitboss/`:
|
||||
pitboss implementer agents are forbidden to write under `.pitboss/`,
|
||||
so the baseline files were placed beside the test that consumes them.
|
||||
|
||||
## Baseline schema
|
||||
|
||||
```json
|
||||
{
|
||||
"_doc": "...",
|
||||
"target": "cal_com",
|
||||
"clone_url": "https://github.com/calcom/cal.com",
|
||||
"exercises_recall_items": [1, 5, 6, 7],
|
||||
"captured_against": "real-scan @ <sha>",
|
||||
"captured_on": "YYYY-MM-DD",
|
||||
"pinned_commit": "<sha>",
|
||||
"findings": [
|
||||
{
|
||||
"rule_id": "taint-unsanitised-flow",
|
||||
"path_suffix": "packages/...",
|
||||
"line": 130,
|
||||
"severity": "High",
|
||||
"verdict": "TP" | "FP" | "needs_review",
|
||||
"note": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The diff key is `(rule_id, path_suffix, line)`. The `verdict` field
|
||||
must be one of `TP`, `FP`, or `needs_review`; unknown verdicts are
|
||||
rejected by the schema test.
|
||||
|
||||
## Usage
|
||||
|
||||
### Diff a fresh scan against the frozen baseline
|
||||
|
||||
```bash
|
||||
scripts/validate_recall.sh cal_com /path/to/cal.com
|
||||
```
|
||||
|
||||
Output is a JSON object `{ added, removed, unchanged, *_total }`
|
||||
keyed by `rule_id`. Use this to spot intentional recall lift
|
||||
(`added`) and regressions (`removed`).
|
||||
|
||||
### Refresh the baseline after an intentional recall lift
|
||||
|
||||
```bash
|
||||
scripts/validate_recall.sh cal_com /path/to/cal.com --capture
|
||||
```
|
||||
|
||||
This overwrites `tests/recall_targets/cal_com.json` with the current
|
||||
scan output. Every finding is re-marked `verdict: "needs_review"`;
|
||||
hand-label `TP`/`FP` afterwards as you triage.
|
||||
|
||||
### Schema-validity check
|
||||
|
||||
```bash
|
||||
cargo test --release --test recall_gaps -- --ignored validate_real_world_targets
|
||||
```
|
||||
|
||||
Loads each per-target JSON, asserts the required keys exist, and
|
||||
asserts every finding carries a valid verdict label.
|
||||
|
||||
## Refresh procedure
|
||||
|
||||
1. Clone or pull the target repo into `~/oss/<target>` (or wherever).
|
||||
2. Build nyx: `cargo build --release`.
|
||||
3. Run the diff in plain mode to see what changed:
|
||||
`scripts/validate_recall.sh <target> ~/oss/<target>`.
|
||||
4. If the lift is intentional, recapture:
|
||||
`scripts/validate_recall.sh <target> ~/oss/<target> --capture`.
|
||||
5. Spot-check a handful of new findings. Open the file at
|
||||
`path_suffix:line` and confirm the source-to-sink flow is real.
|
||||
Hand-label them `TP`/`FP`.
|
||||
6. Commit the updated `tests/recall_targets/<target>.json`.
|
||||
|
||||
## Known captured baselines (2026-05-08)
|
||||
|
||||
| Target | Pinned commit | Findings | TP | FP | needs_review |
|
||||
|-------------------|---------------|----------|----|----|--------------|
|
||||
| `cal_com` | `d278d6c9` | 662 | 0 | 4 | 658 |
|
||||
| `vercel_commerce` | unknown | 0 (placeholder) | | | |
|
||||
| `shadcn_examples` | unknown | 0 (placeholder) | | | |
|
||||
| `blitz_apps` | unknown | 0 (placeholder) | | | |
|
||||
|
||||
The `cal_com` capture used commit `d278d6c9bc535bf3f2c6ba0607654f78dd74d6ee`
|
||||
(`refactor: remove dead insights references (#29029)`). The 4 `FP`
|
||||
labels are `ts.crypto.math_random` hits inside `apps/web/playwright/`
|
||||
test fixtures, which are not a security context.
|
||||
|
||||
The other three targets ship as placeholders (empty `findings`).
|
||||
Nobody has cloned them locally yet. Run `validate_recall.sh
|
||||
<target> <clone> --capture` to populate. The schema test still passes
|
||||
because `[]` is a valid `findings` array with zero entries to check.
|
||||
|
||||
## Perf baseline
|
||||
|
||||
The frozen JS-target perf snapshot lives in
|
||||
`tests/recall_targets/perf_after.txt`. Compare against the
|
||||
`captured_against` snapshot in `tests/recall_gaps_baseline.json`
|
||||
(`corpus_finding_lines.findings_total` = 1121, captured at master
|
||||
`ea82ea98`). The acceptance bar: scanner throughput on the existing
|
||||
`tests/fixtures/` corpus must regress by no more than 15%. Future
|
||||
recall work uses the same corpus and the same record file to measure
|
||||
its own perf delta.
|
||||
|
||||
## Cross-language runbook
|
||||
|
||||
The JS-target baselines above only cover JS/TS. Cross-language
|
||||
baselines mirror that work against real-world non-JS targets so
|
||||
multi-language engine changes can be measured against actual code,
|
||||
not just synthetic fixtures. Per-lang baselines live under
|
||||
`tests/recall_targets/xlang/<lang>/<target>.json` and the runner
|
||||
accepts a `--lang` flag to select the target set.
|
||||
|
||||
### Cross-language targets
|
||||
|
||||
| Lang | Target | Clone URL | Pinned commit (capture) | Findings | Notes |
|
||||
|--------|--------------|----------------------------------------------|-------------------------|----------|-------|
|
||||
| php | phpmyadmin | https://github.com/phpmyadmin/phpmyadmin | `ddf4e993` | 119 | DBA UI; XSS / `php.deser` / `cfg-unguarded-sink` heavy. |
|
||||
| php | joomla | https://github.com/joomla/joomla-cms | `7e8527d0` | 83 | CMS; `php.deser.unserialize` and `php.path.include_variable` clusters. |
|
||||
| php | drupal | https://github.com/drupal/drupal | `92aa759e` | 635 | CMS / DI container; `cfg-unguarded-sink` (198) and `taint-prototype-pollution` (121) dominant. |
|
||||
| php | nextcloud | https://github.com/nextcloud/server | `5c0fe4c3` | 262 | File-sync platform; `cfg-resource-leak` / `state-resource-leak` heavy. |
|
||||
| java | openmrs | https://github.com/openmrs/openmrs-core | `f9c76db2` | 273 | Hibernate-heavy; JPA Criteria fix from `project_realrepo_openmrs.md` already applied. |
|
||||
| python | airflow | https://github.com/apache/airflow | `3d42610a` | 892 | Scheduler / DAG runner; `cfg-unguarded-sink` (252) and `taint-unsanitised-flow` (179) lead. |
|
||||
| python | flask | https://github.com/pallets/flask | placeholder | 0 | Smaller-surface Python framework; capture deferred. |
|
||||
| go | gin | https://github.com/gin-gonic/gin | `d3ffc998` | 20 | HTTP framework test corpus; `taint-header-injection` and TLS skip-verify in tests. |
|
||||
| rust | axum | https://github.com/tokio-rs/axum | placeholder | 0 | Not cloned in pitboss sandbox at capture time; populate locally. |
|
||||
| ruby | rails | https://github.com/rails/rails | placeholder | 0 | Capture against the `actionpack/` subtree once cloned. |
|
||||
|
||||
Captures dated `2026-05-09` (UTC). Counts are deduplicated tuples
|
||||
`(rule_id, path_suffix, line)`. Duplicate raw findings collapse on
|
||||
the diff key, so the schema-test count and diff-mode `unchanged_total`
|
||||
may differ from the `findings | length` total by a handful of
|
||||
duplicate sites. The diff key is what matters for regression
|
||||
detection.
|
||||
|
||||
### Per-lang TP/FP splits
|
||||
|
||||
Every captured finding ships with `verdict: "needs_review"` from
|
||||
`--capture`. Hand-triage is bounded but pending; none of the cross-
|
||||
language captures are sweep-labelled yet. Use the per-lang dominant
|
||||
rule_id clusters above as the priority queue:
|
||||
|
||||
- **PHP**: `cfg-unguarded-sink` and `taint-prototype-pollution` are
|
||||
the FP-dominant clusters across drupal / nextcloud / phpmyadmin
|
||||
(CMS routing + JS object construction). `php.deser.unserialize` is
|
||||
the highest-value TP cluster on joomla (17) and drupal (83). See
|
||||
`project_realrepo_joomla.md` 2026-05-03 for the magic-method
|
||||
passthrough fix that already filters one shape.
|
||||
- **Java**: `taint-unsanitised-flow` (61) and `state-resource-leak`
|
||||
(60) are openmrs's leading clusters. The JPA Criteria-API fix
|
||||
already absorbed the `cfg-unguarded-sink` cluster (216 to 24);
|
||||
remaining Hibernate / Spring resource-management FPs are the next
|
||||
triage target.
|
||||
- **Python**: `cfg-unguarded-sink` (252) on airflow is dominated by
|
||||
Airflow's scheduler / DB plumbing; `py.auth.token_override_*`
|
||||
(83) and `py.auth.missing_ownership_check` (61) are the auth-rule
|
||||
noise typical of an admin/operator codebase.
|
||||
- **Go**: gin's 20 findings are mostly test-corpus artifacts
|
||||
(`gin_test.go`, `routes_test.go`); 4 of 4 `go.transport.insecure_skip_verify`
|
||||
hits are inside `gin*_test.go` and are legitimate test setup.
|
||||
- **Rust / Ruby**: placeholder. Capture once a local clone exists.
|
||||
|
||||
### `--lang` runner usage
|
||||
|
||||
```bash
|
||||
# diff mode (default)
|
||||
scripts/validate_recall.sh --lang php drupal /Users/me/oss/drupal
|
||||
scripts/validate_recall.sh --lang java openmrs /Users/me/oss/openmrs
|
||||
|
||||
# capture / refresh
|
||||
scripts/validate_recall.sh --lang go gin /Users/me/oss/gin --capture
|
||||
```
|
||||
|
||||
Output is the same `{ added, removed, unchanged, *_total }` JSON shape
|
||||
as the JS-target diff. The diff key is `(rule_id, path_suffix, line)`.
|
||||
|
||||
### Cross-language refresh procedure
|
||||
|
||||
1. Clone or update the target into `~/oss/<target>` (or wherever).
|
||||
2. Build nyx: `cargo build --release`.
|
||||
3. Diff vs the frozen baseline:
|
||||
`scripts/validate_recall.sh --lang <lang> <target> ~/oss/<target>`.
|
||||
4. If the lift is intentional, recapture with `--capture`.
|
||||
5. Spot-check new findings; hand-label `TP`/`FP`.
|
||||
6. Commit the updated `tests/recall_targets/xlang/<lang>/<target>.json`.
|
||||
|
||||
### Sandbox-capture caveat
|
||||
|
||||
Pitboss implementer agents run sandboxed without network egress, so
|
||||
target repos that are not already present under `~/oss/` ship as
|
||||
placeholders (`pinned_commit: "unknown"`, `findings: []`). The
|
||||
current cross-language baselines cover php / java / python / go
|
||||
(every target whose repo was already cloned locally) and ship
|
||||
placeholders for `rust/axum`, `ruby/rails`, and `python/flask`. The
|
||||
schema test in `validate_real_world_targets` passes against
|
||||
placeholders because `[]` is a valid `findings` array.
|
||||
|
||||
## What lives where (quick reference)
|
||||
|
||||
- Targets list and recall-item mapping in this file.
|
||||
- Per-target JS findings under `tests/recall_targets/<target>.json`.
|
||||
- Per-target cross-lang findings under `tests/recall_targets/xlang/<lang>/<target>.json`.
|
||||
- Diff/capture runner at `scripts/validate_recall.sh` (accepts `--lang`).
|
||||
- Schema-validity test at `tests/recall_gaps.rs::validate_real_world_targets`.
|
||||
- Corpus regression baseline at `tests/recall_gaps_baseline.json`.
|
||||
- Perf records at `tests/recall_targets/perf_after.txt` (JS-target
|
||||
snapshot) and `tests/recall_targets/perf_after_xlang.txt`
|
||||
(cross-language delta).
|
||||
|
|
@ -121,7 +121,7 @@ The tables below are generated from `src/patterns/<lang>.rs` by [`tools/docgen`]
|
|||
| `go.crypto.md5` | Low | A | Medium |
|
||||
| `go.crypto.sha1` | Low | A | Medium |
|
||||
|
||||
### Java: 10 patterns
|
||||
### Java: 9 patterns
|
||||
|
||||
| Rule ID | Severity | Tier | Confidence |
|
||||
|---|---|---|---|
|
||||
|
|
@ -129,14 +129,13 @@ The tables below are generated from `src/patterns/<lang>.rs` by [`tools/docgen`]
|
|||
| `java.code_exec.text4shell_interpolator` | High | A | High |
|
||||
| `java.deser.readobject` | High | A | High |
|
||||
| `java.deser.snakeyaml_unsafe_constructor` | High | A | High |
|
||||
| `java.crypto.weak_algorithm` | Medium | A | Medium |
|
||||
| `java.reflection.class_forname` | Medium | A | High |
|
||||
| `java.reflection.method_invoke` | Medium | A | High |
|
||||
| `java.sqli.execute_concat` | Medium | B | Medium |
|
||||
| `java.xss.getwriter_print` | Medium | A | High |
|
||||
| `java.crypto.insecure_random` | Low | A | Medium |
|
||||
| `java.crypto.weak_digest` | Low | A | Medium |
|
||||
|
||||
### JavaScript: 22 patterns
|
||||
### JavaScript: 23 patterns
|
||||
|
||||
| Rule ID | Severity | Tier | Confidence |
|
||||
|---|---|---|---|
|
||||
|
|
@ -158,6 +157,7 @@ The tables below are generated from `src/patterns/<lang>.rs` by [`tools/docgen`]
|
|||
| `js.xss.outer_html` | Medium | A | High |
|
||||
| `js.config.insecure_session_samesite` | Low | A | High |
|
||||
| `js.config.insecure_session_secure` | Low | A | Medium |
|
||||
| `js.crypto.hardcoded_key` | Low | A | Medium |
|
||||
| `js.crypto.math_random` | Low | A | Medium |
|
||||
| `js.crypto.weak_hash` | Low | A | Medium |
|
||||
| `js.secrets.hardcoded_secret` | Low | A | Medium |
|
||||
|
|
@ -179,7 +179,7 @@ The tables below are generated from `src/patterns/<lang>.rs` by [`tools/docgen`]
|
|||
| `php.crypto.rand` | Low | A | Medium |
|
||||
| `php.crypto.sha1` | Low | A | Medium |
|
||||
|
||||
### Python: 15 patterns
|
||||
### Python: 17 patterns
|
||||
|
||||
| Rule ID | Severity | Tier | Confidence |
|
||||
|---|---|---|---|
|
||||
|
|
@ -197,7 +197,9 @@ The tables below are generated from `src/patterns/<lang>.rs` by [`tools/docgen`]
|
|||
| `py.xss.jinja_from_string` | Medium | A | High |
|
||||
| `py.xss.make_response_format` | Medium | B | Medium |
|
||||
| `py.crypto.md5` | Low | A | Medium |
|
||||
| `py.crypto.md5_bare` | Low | A | Low |
|
||||
| `py.crypto.sha1` | Low | A | Medium |
|
||||
| `py.crypto.sha1_bare` | Low | A | Low |
|
||||
|
||||
### Ruby: 11 patterns
|
||||
|
||||
|
|
@ -233,7 +235,7 @@ The tables below are generated from `src/patterns/<lang>.rs` by [`tools/docgen`]
|
|||
| `rs.quality.todo` | Low | A | High |
|
||||
| `rs.quality.unwrap` | Low | A | High |
|
||||
|
||||
### TypeScript: 22 patterns
|
||||
### TypeScript: 23 patterns
|
||||
|
||||
| Rule ID | Severity | Tier | Confidence |
|
||||
|---|---|---|---|
|
||||
|
|
@ -253,6 +255,7 @@ The tables below are generated from `src/patterns/<lang>.rs` by [`tools/docgen`]
|
|||
| `ts.xss.outer_html` | Medium | A | High |
|
||||
| `ts.config.insecure_session_samesite` | Low | A | High |
|
||||
| `ts.config.insecure_session_secure` | Low | A | Medium |
|
||||
| `ts.crypto.hardcoded_key` | Low | A | Medium |
|
||||
| `ts.crypto.math_random` | Low | A | Medium |
|
||||
| `ts.crypto.weak_hash` | Low | A | Medium |
|
||||
| `ts.quality.any_annotation` | Low | A | Medium |
|
||||
|
|
|
|||
|
|
@ -11,6 +11,20 @@ nyx serve --no-browser # don't auto-open
|
|||
|
||||
Persistent settings live under `[server]` in `nyx.conf` / `nyx.local`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Scan["nyx scan<br/>or UI-started scan"] --> Cache[".nyx findings<br/>plus SQLite project index"]
|
||||
Cache --> Serve["nyx serve<br/>loopback API and embedded React UI"]
|
||||
Serve --> Review["Review findings<br/>flow, evidence, history"]
|
||||
Review --> Triage["Update triage state<br/>investigate, suppress, accept, fix"]
|
||||
Triage --> Sync[".nyx/triage.json<br/>optional repo-synced state"]
|
||||
Sync --> Cache
|
||||
```
|
||||
|
||||
Starting a scan from the UI runs dynamic verification on `Confidence >= Medium`
|
||||
findings by default. Check "Skip dynamic verification" in the scan modal to get
|
||||
a fast static-only result. See [Dynamic verification](dynamic.md) for details.
|
||||
|
||||
<p align="center"><img src="assets/screenshots/docs/serve-overview.png" alt="Nyx UI overview: total findings, severity breakdown, language and category distribution, top affected files" width="900"/></p>
|
||||
|
||||
## What it serves, and what it doesn't
|
||||
|
|
@ -21,10 +35,10 @@ There is **no** account, no telemetry, no remote logging, no auto-update ping. T
|
|||
|
||||
## Security model
|
||||
|
||||
`nyx serve` enforces three things at the HTTP layer ([`src/server/security.rs`](https://github.com/elicpeter/nyx/blob/master/src/server/security.rs)):
|
||||
`nyx serve` enforces three things:
|
||||
|
||||
1. **Loopback bind only.** `--host` and `[server].host` are clamped to `127.0.0.1`, `localhost`, or `::1`. Any other value is refused at startup with `Nyx serve only binds to loopback addresses; refused host '<value>'`.
|
||||
2. **Host-header check.** Every request must carry a `Host` header that matches the bound address and port. Missing or mismatched headers get a `400 invalid Host header`. Defends against DNS rebinding.
|
||||
1. **Loopback bind only.** `--host` and `[server].host` are clamped to `127.0.0.1`, `localhost`, or `::1`. Any other value is refused at startup with `Nyx serve only binds to loopback addresses; refused host '<value>'` ([`src/commands/serve.rs`](https://github.com/elicpeter/nyx/blob/master/src/commands/serve.rs)).
|
||||
2. **Host-header check.** Every request must carry a `Host` header that matches the bound address and port. Missing or mismatched headers get a `400 invalid Host header`. Defends against DNS rebinding ([`src/server/security.rs`](https://github.com/elicpeter/nyx/blob/master/src/server/security.rs)).
|
||||
3. **CSRF on mutations.** `POST` / `PUT` / `PATCH` / `DELETE` requests must carry a per-process CSRF token in the `x-nyx-csrf` header. The token is generated once when the server starts and exposed at `GET /api/health` so the embedded SPA can read it. Cross-origin mutations are rejected before the CSRF check via the `Origin` header.
|
||||
|
||||
If you forward the port over SSH or expose it through a reverse proxy, the host-header check will reject the request because the `Host` won't match `localhost:9700`. That's the intended behaviour. Don't do this without a deliberate reason; the loopback bind is part of the security model.
|
||||
|
|
@ -82,7 +96,7 @@ Modifiers in the ±5 range nudge the result for trend (only after the second sca
|
|||
|
||||
It's a Nyx-finding-pressure metric, not a security audit. Score 100 means Nyx didn't find anything under its current rules and language coverage; it doesn't certify the absence of vulnerabilities. The score doesn't see runtime config, IAM, secret stores, dependency CVEs, or anything outside the source tree being scanned. A repo of mostly Kotlin (where Nyx coverage is thin) will score artificially well because most of the code never gets evaluated.
|
||||
|
||||
Ceilings are calibrated for the current scanner false-positive rates. As symex coverage and rule precision improve, the ceilings tighten. Calibration data and the rationale behind each tunable lives in [health-score-audit.md](health-score-audit.md).
|
||||
Ceilings are calibrated for the current scanner false-positive rates. As symex coverage and rule precision improve, the ceilings may tighten.
|
||||
|
||||
### Findings and Finding detail
|
||||
|
||||
|
|
@ -94,7 +108,7 @@ Clicking through opens the **flow visualiser**: a numbered walk from source to s
|
|||
|
||||
<p align="center"><img src="assets/screenshots/docs/serve-finding-detail.png" alt="Nyx finding detail: HIGH taint-unsanitised-flow showing source → call → sink steps, How to fix guidance, and evidence panel" width="900"/></p>
|
||||
|
||||
Engine notes call out when precision was bounded for that finding (`OriginsTruncated`, `PointsToTruncated`, `PathWidened`, `ForwardBailed`, etc.). Anything tagged `under-report` means the emitted flow is real and the result set is a lower bound; `over-report` means widening or bail. `--require-converged` in the CLI drops the over-report ones for strict gates.
|
||||
Engine notes call out when precision was bounded for that finding (`OriginsTruncated`, `PointsToTruncated`, `WorklistCapped`, `PredicateStateWidened`, `SsaLoweringBailed`, etc.). Each note carries a direction tag: `under-report` means the emitted flow is real and the result set is a lower bound; `over-report` means widening dropped a guard; `bail` means analysis aborted before producing a trustworthy result. `--require-converged` in the CLI drops over-report and bail notes for strict gates.
|
||||
|
||||
### Triage
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,26 @@ import type { ScanView } from '../types';
|
|||
|
||||
export type ScanMode = 'full' | 'ast' | 'cfg' | 'taint';
|
||||
export type EngineProfile = 'fast' | 'balanced' | 'deep';
|
||||
export type VerifyBackend = 'auto' | 'docker' | 'process' | 'firecracker';
|
||||
export type HardenProfile = 'standard' | 'strict';
|
||||
|
||||
export interface StartScanBody {
|
||||
scan_root?: string;
|
||||
mode?: ScanMode;
|
||||
engine_profile?: EngineProfile;
|
||||
/**
|
||||
* Override dynamic verification for this scan.
|
||||
* true - force on.
|
||||
* false - force off.
|
||||
* absent - use server config default.
|
||||
*/
|
||||
verify?: boolean;
|
||||
/** Also verify Confidence < Medium findings. Default false. */
|
||||
verify_all_confidence?: boolean;
|
||||
/** Sandbox backend for dynamic verification. */
|
||||
verify_backend?: VerifyBackend;
|
||||
/** Process-backend hardening profile. */
|
||||
harden_profile?: HardenProfile;
|
||||
}
|
||||
|
||||
export function useStartScan() {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface FindingsParams {
|
|||
language?: string;
|
||||
rule_id?: string;
|
||||
status?: string;
|
||||
verification?: string;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
|
|
|
|||
11
frontend/src/api/queries/surface.ts
Normal file
11
frontend/src/api/queries/surface.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiGet } from '../client';
|
||||
import type { SurfaceMap } from '../types';
|
||||
|
||||
export function useSurfaceMap() {
|
||||
return useQuery({
|
||||
queryKey: ['surface'],
|
||||
queryFn: ({ signal }) => apiGet<SurfaceMap>('/surface', signal),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
43
frontend/src/api/queries/targets.ts
Normal file
43
frontend/src/api/queries/targets.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiDelete, apiGet, apiPost } from '../client';
|
||||
import type { TargetView } from '../types';
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryKey: ['targets'],
|
||||
queryFn: ({ signal }) => apiGet<TargetView[]>('/targets', signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTarget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { path: string }) =>
|
||||
apiPost<TargetView>('/targets', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['targets'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSelectTarget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { id?: string; path?: string }) =>
|
||||
apiPost<TargetView>('/targets/select', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTarget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<void>(`/targets/${encodeURIComponent(id)}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['targets'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -2,6 +2,44 @@
|
|||
export type Confidence = 'Low' | 'Medium' | 'High';
|
||||
export type FlowStepKind = 'source' | 'assignment' | 'call' | 'phi' | 'sink';
|
||||
|
||||
// Dynamic verification types (from src/evidence.rs VerifyStatus / VerifyResult)
|
||||
export type VerifyStatus =
|
||||
| 'Confirmed'
|
||||
| 'PartiallyConfirmed'
|
||||
| 'NotConfirmed'
|
||||
| 'Inconclusive'
|
||||
| 'Unsupported';
|
||||
|
||||
export interface AttemptSummary {
|
||||
payload_label: string;
|
||||
exit_code?: number;
|
||||
timed_out: boolean;
|
||||
triggered: boolean;
|
||||
sink_hit?: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
finding_id: string;
|
||||
status: VerifyStatus;
|
||||
triggered_payload?: string;
|
||||
/** Typed UnsupportedReason (PascalCase string) */
|
||||
reason?: string;
|
||||
/** Typed InconclusiveReason (PascalCase string) */
|
||||
inconclusive_reason?: string;
|
||||
detail?: string;
|
||||
attempts?: AttemptSummary[];
|
||||
toolchain_match?: string;
|
||||
}
|
||||
|
||||
export interface DynamicVerificationSummary {
|
||||
total: number;
|
||||
confirmed: number;
|
||||
partially_confirmed: number;
|
||||
not_confirmed: number;
|
||||
inconclusive: number;
|
||||
unsupported: number;
|
||||
}
|
||||
|
||||
export interface FlowStep {
|
||||
step: number;
|
||||
kind: FlowStepKind;
|
||||
|
|
@ -40,6 +78,8 @@ export interface Evidence {
|
|||
flow_steps: FlowStep[];
|
||||
explanation?: string;
|
||||
confidence_limiters: string[];
|
||||
/** Dynamic verification result; present only when --verify was active. */
|
||||
dynamic_verdict?: VerifyResult;
|
||||
}
|
||||
|
||||
// Finding types
|
||||
|
|
@ -57,10 +97,31 @@ export interface RelatedFindingView {
|
|||
severity: string;
|
||||
}
|
||||
|
||||
// Baseline / patch-validation types (M6.5)
|
||||
export type VerdictTransition =
|
||||
| 'New'
|
||||
| 'Unchanged'
|
||||
| 'Resolved'
|
||||
| 'Regressed'
|
||||
| 'FlippedConfirmed'
|
||||
| 'FlippedNotConfirmed';
|
||||
|
||||
export interface VerdictDiffEntry {
|
||||
stable_hash: number;
|
||||
path: string;
|
||||
line: number;
|
||||
rule_id: string;
|
||||
baseline_status?: VerifyStatus;
|
||||
current_status?: VerifyStatus;
|
||||
transition: VerdictTransition;
|
||||
}
|
||||
|
||||
export interface FindingView {
|
||||
index: number;
|
||||
fingerprint: string;
|
||||
portable_fingerprint?: string;
|
||||
/** Blake3-derived stable cross-commit identity (M6.5). */
|
||||
stable_hash?: number;
|
||||
path: string;
|
||||
line: number;
|
||||
col: number;
|
||||
|
|
@ -79,6 +140,7 @@ export interface FindingView {
|
|||
triage_note?: string;
|
||||
code_context?: CodeContextView;
|
||||
evidence?: Evidence;
|
||||
dynamic_verdict?: VerifyResult;
|
||||
guard_kind?: string;
|
||||
rank_reason?: [string, string][];
|
||||
sanitizer_status?: string;
|
||||
|
|
@ -100,6 +162,7 @@ export interface FilterValues {
|
|||
languages: string[];
|
||||
rules: string[];
|
||||
statuses: string[];
|
||||
verification_statuses: string[];
|
||||
}
|
||||
|
||||
// Scan types
|
||||
|
|
@ -135,6 +198,17 @@ export interface ScanView {
|
|||
metrics?: ScanMetricsSnapshot;
|
||||
}
|
||||
|
||||
export interface TargetView {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
db_path: string;
|
||||
last_seen_at: string;
|
||||
last_scan_at?: string;
|
||||
active: boolean;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
// Scan Comparison types
|
||||
export interface CompareScanInfo {
|
||||
id: string;
|
||||
|
|
@ -173,6 +247,8 @@ export interface CompareResponse {
|
|||
fixed_findings: ComparedFinding[];
|
||||
changed_findings: ChangedFinding[];
|
||||
unchanged_findings: ComparedFinding[];
|
||||
/** Verdict-level diff (M6.5). Present when findings carry stable_hash values. */
|
||||
verdict_diff?: VerdictDiffEntry[];
|
||||
}
|
||||
|
||||
// Overview types
|
||||
|
|
@ -302,6 +378,7 @@ export interface ScannerQuality {
|
|||
call_resolution_rate: number;
|
||||
symex_verified_rate: number;
|
||||
symex_breakdown: Record<string, number>;
|
||||
dynamic_verification: DynamicVerificationSummary;
|
||||
}
|
||||
|
||||
export interface IssueCategoryBucket {
|
||||
|
|
@ -843,3 +920,106 @@ export interface AuthAnalysisView {
|
|||
units: AuthUnitView[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ── Surface map (Phase 21–23) ───────────────────────────────────────
|
||||
|
||||
export interface SurfaceSourceLocation {
|
||||
file: string;
|
||||
line: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export type SurfaceFramework =
|
||||
| 'flask'
|
||||
| 'fast_api'
|
||||
| 'django'
|
||||
| 'express'
|
||||
| 'koa'
|
||||
| 'spring'
|
||||
| 'jax_rs'
|
||||
| 'quarkus'
|
||||
| 'rails'
|
||||
| 'sinatra'
|
||||
| 'laravel'
|
||||
| 'slim'
|
||||
| 'axum'
|
||||
| 'actix'
|
||||
| 'rocket'
|
||||
| 'net_http'
|
||||
| 'gin'
|
||||
| 'next_app_router'
|
||||
| 'next_server_action';
|
||||
|
||||
export type SurfaceHttpMethod =
|
||||
| 'GET'
|
||||
| 'HEAD'
|
||||
| 'POST'
|
||||
| 'PUT'
|
||||
| 'PATCH'
|
||||
| 'DELETE'
|
||||
| 'OPTIONS';
|
||||
|
||||
export type SurfaceDataStoreKind =
|
||||
| 'sql'
|
||||
| 'key_value'
|
||||
| 'document'
|
||||
| 'blob_store'
|
||||
| 'filesystem'
|
||||
| 'unknown';
|
||||
|
||||
export type SurfaceExternalKind =
|
||||
| 'http_api'
|
||||
| 'message_broker'
|
||||
| 'search_index'
|
||||
| 'auth_provider'
|
||||
| 'unknown';
|
||||
|
||||
export type SurfaceEdgeKind =
|
||||
| 'calls'
|
||||
| 'reads_from'
|
||||
| 'writes_to'
|
||||
| 'talks_to'
|
||||
| 'reaches'
|
||||
| 'triggers'
|
||||
| 'auth_required_on';
|
||||
|
||||
export type SurfaceNode =
|
||||
| {
|
||||
node: 'entry_point';
|
||||
location: SurfaceSourceLocation;
|
||||
framework: SurfaceFramework;
|
||||
method: SurfaceHttpMethod;
|
||||
route: string;
|
||||
handler_name: string;
|
||||
handler_location: SurfaceSourceLocation;
|
||||
auth_required: boolean;
|
||||
}
|
||||
| {
|
||||
node: 'data_store';
|
||||
location: SurfaceSourceLocation;
|
||||
kind: SurfaceDataStoreKind;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
node: 'external_service';
|
||||
location: SurfaceSourceLocation;
|
||||
kind: SurfaceExternalKind;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
node: 'dangerous_local';
|
||||
location: SurfaceSourceLocation;
|
||||
function_name: string;
|
||||
cap_bits: number;
|
||||
};
|
||||
|
||||
export interface SurfaceEdge {
|
||||
from: number;
|
||||
to: number;
|
||||
kind: SurfaceEdgeKind;
|
||||
}
|
||||
|
||||
export interface SurfaceMap {
|
||||
nodes: SurfaceNode[];
|
||||
edges: SurfaceEdge[];
|
||||
}
|
||||
|
|
|
|||
64
frontend/src/components/VerdictBadge.tsx
Normal file
64
frontend/src/components/VerdictBadge.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { VerifyResult, VerifyStatus } from '../api/types';
|
||||
|
||||
const STATUS_LABELS: Record<VerifyStatus, string> = {
|
||||
Confirmed: 'Confirmed',
|
||||
PartiallyConfirmed: 'Partially confirmed',
|
||||
NotConfirmed: 'Not confirmed',
|
||||
Inconclusive: 'Inconclusive',
|
||||
Unsupported: 'Unsupported',
|
||||
};
|
||||
|
||||
function verdictTooltip(verdict: VerifyResult): string {
|
||||
const { status, triggered_payload, reason, inconclusive_reason, detail } =
|
||||
verdict;
|
||||
switch (status) {
|
||||
case 'Confirmed':
|
||||
return triggered_payload
|
||||
? `Confirmed via payload: ${triggered_payload}`
|
||||
: 'Dynamically confirmed exploitable';
|
||||
case 'PartiallyConfirmed':
|
||||
return detail
|
||||
? `Partially confirmed (sink reached): ${detail}`
|
||||
: 'Partially confirmed: sink reached but exploit chain did not complete';
|
||||
case 'NotConfirmed':
|
||||
return (verdict.attempts?.length ?? 0) > 0
|
||||
? `Not confirmed after ${verdict.attempts?.length ?? 0} payload attempt(s)`
|
||||
: 'Not confirmed';
|
||||
case 'Unsupported':
|
||||
return reason
|
||||
? `Unsupported: ${reason}`
|
||||
: 'Dynamic verification not supported';
|
||||
case 'Inconclusive':
|
||||
return inconclusive_reason
|
||||
? `Inconclusive: ${inconclusive_reason}${detail ? `: ${detail}` : ''}`
|
||||
: detail || 'Inconclusive';
|
||||
}
|
||||
}
|
||||
|
||||
interface VerdictBadgeProps {
|
||||
verdict: VerifyResult | undefined;
|
||||
/** Show full label (default) or compact icon-only mode */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function VerdictBadge({ verdict, compact = false }: VerdictBadgeProps) {
|
||||
if (!verdict) {
|
||||
return <span style={{ color: 'var(--text-tertiary)' }}>-</span>;
|
||||
}
|
||||
|
||||
const { status } = verdict;
|
||||
const label = STATUS_LABELS[status] ?? status;
|
||||
const tooltip = verdictTooltip(verdict);
|
||||
const flame = status === 'Confirmed' ? '🔥 ' : '';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`badge badge-dyn-${status.toLowerCase()}`}
|
||||
title={tooltip}
|
||||
data-testid={`verdict-badge-${status.toLowerCase()}`}
|
||||
>
|
||||
{flame}
|
||||
{compact ? status.charAt(0) : label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { RulesPage } from '../../pages/RulesPage';
|
|||
import { TriagePage } from '../../pages/TriagePage';
|
||||
import { ConfigPage } from '../../pages/ConfigPage';
|
||||
import { ExplorerPage } from '../../pages/ExplorerPage';
|
||||
import { SurfacePage } from '../../pages/SurfacePage';
|
||||
import { DebugLayout } from '../../pages/debug/DebugLayout';
|
||||
import { CallGraphPage } from '../../pages/debug/CallGraphPage';
|
||||
import { SummaryExplorerPage } from '../../pages/debug/SummaryExplorerPage';
|
||||
|
|
@ -50,6 +51,12 @@ export function AppLayout() {
|
|||
label: 'Explorer',
|
||||
to: '/explorer',
|
||||
},
|
||||
{
|
||||
id: 'go-surface',
|
||||
group: 'Navigate',
|
||||
label: 'Attack surface',
|
||||
to: '/surface',
|
||||
},
|
||||
{
|
||||
id: 'go-debug-cg',
|
||||
group: 'Navigate',
|
||||
|
|
@ -141,6 +148,7 @@ export function AppLayout() {
|
|||
<Route path="/triage" element={<TriagePage />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/explorer" element={<ExplorerPage />} />
|
||||
<Route path="/surface" element={<SurfacePage />} />
|
||||
<Route path="/debug" element={<DebugLayout />}>
|
||||
<Route
|
||||
index
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ import {
|
|||
ConfigIcon,
|
||||
ExplorerIcon,
|
||||
DebugIcon,
|
||||
FolderIcon,
|
||||
TagIcon,
|
||||
} from '../icons/Icons';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef, useState, type FC, type FormEvent } from 'react';
|
||||
import type { IconProps } from '../icons/Icons';
|
||||
import { useHealth } from '../../api/queries/health';
|
||||
import { useOverview } from '../../api/queries/overview';
|
||||
import {
|
||||
useAddTarget,
|
||||
useSelectTarget,
|
||||
useTargets,
|
||||
} from '../../api/queries/targets';
|
||||
import { useSSE } from '../../contexts/SSEContext';
|
||||
|
||||
interface NavItem {
|
||||
|
|
@ -68,6 +72,13 @@ const NAV_SECTIONS: NavItem[] = [
|
|||
Icon: ExplorerIcon,
|
||||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'surface',
|
||||
label: 'Surface',
|
||||
path: '/surface',
|
||||
Icon: ExplorerIcon,
|
||||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'debug',
|
||||
label: 'Debug',
|
||||
|
|
@ -88,6 +99,167 @@ function navLinkClass({ isActive }: { isActive: boolean }) {
|
|||
return `nav-link${isActive ? ' active' : ''}`;
|
||||
}
|
||||
|
||||
function targetNameFromPath(path: string) {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || path || 'Project';
|
||||
}
|
||||
|
||||
function targetInitial(name: string) {
|
||||
return name.trim().charAt(0).toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function compactPath(path: string) {
|
||||
return path.replace(/^\/Users\/[^/]+/, '~');
|
||||
}
|
||||
|
||||
function TargetSwitcher({ scanRoot }: { scanRoot?: string }) {
|
||||
const { data: targets = [] } = useTargets();
|
||||
const addTarget = useAddTarget();
|
||||
const selectTarget = useSelectTarget();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newPath, setNewPath] = useState('');
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const activeTarget =
|
||||
targets.find((target) => target.active) ??
|
||||
(scanRoot
|
||||
? {
|
||||
id: '__active__',
|
||||
name: targetNameFromPath(scanRoot),
|
||||
path: scanRoot,
|
||||
active: true,
|
||||
exists: true,
|
||||
}
|
||||
: undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (
|
||||
menuRef.current &&
|
||||
event.target instanceof Node &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function handleSelect(id: string) {
|
||||
selectTarget.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => setOpen(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleAddSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const path = newPath.trim();
|
||||
if (!path || addTarget.isPending) return;
|
||||
addTarget.mutate(
|
||||
{ path },
|
||||
{
|
||||
onSuccess: (target) => {
|
||||
setNewPath('');
|
||||
selectTarget.mutate(
|
||||
{ id: target.id },
|
||||
{
|
||||
onSuccess: () => setOpen(false),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isBusy = addTarget.isPending || selectTarget.isPending;
|
||||
const errorMessage =
|
||||
addTarget.error instanceof Error ? addTarget.error.message : null;
|
||||
|
||||
return (
|
||||
<div className="target-switcher" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="target-trigger"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-label="Select project target"
|
||||
title={activeTarget?.path}
|
||||
>
|
||||
<span className="target-avatar">
|
||||
{targetInitial(activeTarget?.name ?? 'Project')}
|
||||
</span>
|
||||
<span className="target-trigger-copy">
|
||||
<span className="target-name">
|
||||
{activeTarget?.name ?? 'Select target'}
|
||||
</span>
|
||||
<span className="target-path">
|
||||
{activeTarget?.path ? compactPath(activeTarget.path) : 'No target'}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`target-caret${open ? ' open' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="target-menu" role="menu">
|
||||
<div className="target-options">
|
||||
{targets.map((target) => (
|
||||
<button
|
||||
key={target.id}
|
||||
type="button"
|
||||
className={`target-option${target.active ? ' active' : ''}`}
|
||||
onClick={() => handleSelect(target.id)}
|
||||
disabled={target.active || !target.exists || isBusy}
|
||||
title={target.path}
|
||||
>
|
||||
<span className="target-option-avatar">
|
||||
{targetInitial(target.name)}
|
||||
</span>
|
||||
<span className="target-option-copy">
|
||||
<span className="target-option-name">{target.name}</span>
|
||||
<span className="target-option-path">
|
||||
{target.exists ? compactPath(target.path) : 'Missing path'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="target-add-form" onSubmit={handleAddSubmit}>
|
||||
<input
|
||||
value={newPath}
|
||||
onChange={(event) => setNewPath(event.target.value)}
|
||||
placeholder="/path/to/project"
|
||||
aria-label="Project path"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="target-add-button"
|
||||
disabled={!newPath.trim() || addTarget.isPending}
|
||||
title="Add target"
|
||||
aria-label="Add target"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
{errorMessage && <div className="target-error">{errorMessage}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { data: health } = useHealth();
|
||||
const { data: overview } = useOverview();
|
||||
|
|
@ -105,6 +277,8 @@ export function Sidebar() {
|
|||
<img src="/logo.png" alt="Nyx" className="sidebar-logo-img" />
|
||||
</div>
|
||||
|
||||
<TargetSwitcher scanRoot={health?.scan_root} />
|
||||
|
||||
<ul className="nav-list">
|
||||
{primary.map((item) => (
|
||||
<li key={item.id}>
|
||||
|
|
@ -154,12 +328,6 @@ export function Sidebar() {
|
|||
</div>
|
||||
|
||||
<div className="sidebar-meta">
|
||||
{health?.scan_root && (
|
||||
<div className="sidebar-meta-item" title={health.scan_root}>
|
||||
<FolderIcon />
|
||||
<span>{health.scan_root}</span>
|
||||
</div>
|
||||
)}
|
||||
{health?.version && (
|
||||
<div className="sidebar-meta-item">
|
||||
<TagIcon />
|
||||
|
|
|
|||
|
|
@ -241,6 +241,18 @@ export function ScannerQualityPanel({
|
|||
: quality.files_scanned > 0
|
||||
? `${quality.files_scanned.toLocaleString()} freshly indexed`
|
||||
: undefined;
|
||||
const dynamic = quality.dynamic_verification ?? {
|
||||
total: 0,
|
||||
confirmed: 0,
|
||||
partially_confirmed: 0,
|
||||
not_confirmed: 0,
|
||||
inconclusive: 0,
|
||||
unsupported: 0,
|
||||
};
|
||||
const dynamicDetail =
|
||||
dynamic.total > 0
|
||||
? `${dynamic.total.toLocaleString()} verdicts · ${dynamic.partially_confirmed.toLocaleString()} partially confirmed · ${dynamic.not_confirmed.toLocaleString()} not confirmed · ${dynamic.inconclusive.toLocaleString()} inconclusive · ${dynamic.unsupported.toLocaleString()} unsupported`
|
||||
: 'no dynamic verdicts in latest scan';
|
||||
|
||||
const rows: Array<{
|
||||
label: string;
|
||||
|
|
@ -287,6 +299,15 @@ export function ScannerQualityPanel({
|
|||
? `${symexAttempted} of ${symexTotal} taint findings`
|
||||
: 'no taint findings',
|
||||
},
|
||||
{
|
||||
label: 'Dynamic verification',
|
||||
hint: 'Findings re-run in generated harnesses against the dynamic payload corpus.',
|
||||
value:
|
||||
dynamic.total > 0
|
||||
? `${dynamic.confirmed.toLocaleString()} confirmed`
|
||||
: 'not run',
|
||||
detail: dynamicDetail,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export function SSEProvider({ children }: { children: ReactNode }) {
|
|||
es.addEventListener('scan_started', () => {
|
||||
setIsScanRunning(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['scans'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['targets'] });
|
||||
});
|
||||
|
||||
es.addEventListener('scan_progress', (e) => {
|
||||
|
|
@ -75,12 +76,14 @@ export function SSEProvider({ children }: { children: ReactNode }) {
|
|||
queryClient.invalidateQueries({ queryKey: ['scans'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['overview'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['findings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['targets'] });
|
||||
});
|
||||
|
||||
es.addEventListener('scan_failed', () => {
|
||||
setScanProgress(null);
|
||||
setIsScanRunning(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['scans'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['targets'] });
|
||||
});
|
||||
|
||||
es.addEventListener('config_changed', () => {
|
||||
|
|
|
|||
84
frontend/src/graph/adapters/surface.ts
Normal file
84
frontend/src/graph/adapters/surface.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { SurfaceEdge, SurfaceMap, SurfaceNode } from '@/api/types';
|
||||
import type { GraphModel } from '../types';
|
||||
|
||||
const MAX_LABEL = 44;
|
||||
const MAX_DETAIL = 48;
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
||||
}
|
||||
|
||||
export const SURFACE_NODE_KIND: Record<SurfaceNode['node'], string> = {
|
||||
entry_point: 'EntryPoint',
|
||||
data_store: 'DataStore',
|
||||
external_service: 'ExternalService',
|
||||
dangerous_local: 'DangerousLocal',
|
||||
};
|
||||
|
||||
function nodeTitle(node: SurfaceNode): string {
|
||||
switch (node.node) {
|
||||
case 'entry_point':
|
||||
return `${node.method} ${node.route}`;
|
||||
case 'data_store':
|
||||
return `${node.kind}: ${node.label}`;
|
||||
case 'external_service':
|
||||
return `${node.kind}: ${node.label}`;
|
||||
case 'dangerous_local':
|
||||
return node.function_name;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeDetail(node: SurfaceNode): string {
|
||||
switch (node.node) {
|
||||
case 'entry_point':
|
||||
return `${node.framework} · ${node.handler_name}`;
|
||||
case 'data_store':
|
||||
return 'data store';
|
||||
case 'external_service':
|
||||
return 'external service';
|
||||
case 'dangerous_local':
|
||||
return `cap=0x${node.cap_bits.toString(16)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeLocation(node: SurfaceNode): { file: string; line: number } {
|
||||
if (node.node === 'entry_point') return node.handler_location;
|
||||
return node.location;
|
||||
}
|
||||
|
||||
export function adaptSurfaceMap(data: SurfaceMap): GraphModel {
|
||||
return {
|
||||
kind: 'surface',
|
||||
nodes: data.nodes.map((node, index) => {
|
||||
const loc = nodeLocation(node);
|
||||
const title = nodeTitle(node);
|
||||
const detail = nodeDetail(node);
|
||||
const searchText = [title, detail, loc.file].join(' ').toLowerCase();
|
||||
const authBadge =
|
||||
node.node === 'entry_point' && node.auth_required
|
||||
? ['auth']
|
||||
: undefined;
|
||||
return {
|
||||
key: String(index),
|
||||
rawId: index,
|
||||
label: truncate(title, MAX_LABEL),
|
||||
kind: SURFACE_NODE_KIND[node.node],
|
||||
detail: truncate(detail, MAX_DETAIL),
|
||||
line: loc.line,
|
||||
badges: authBadge,
|
||||
metadata: {
|
||||
surfaceKind: node.node,
|
||||
node,
|
||||
searchText,
|
||||
},
|
||||
};
|
||||
}),
|
||||
edges: data.edges.map((edge: SurfaceEdge, index) => ({
|
||||
key: `surface:${edge.from}:${edge.to}:${edge.kind}:${index}`,
|
||||
source: String(edge.from),
|
||||
target: String(edge.to),
|
||||
kind: edge.kind,
|
||||
metadata: { ...edge },
|
||||
})),
|
||||
};
|
||||
}
|
||||
123
frontend/src/graph/components/SurfaceGraphCanvas.tsx
Normal file
123
frontend/src/graph/components/SurfaceGraphCanvas.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import type { SurfaceMap } from '@/api/types';
|
||||
import { adaptSurfaceMap } from '../adapters/surface';
|
||||
import { useElkLayout } from '../hooks/useElkLayout';
|
||||
import {
|
||||
collectSearchMatches,
|
||||
extractNeighborhoodSubgraph,
|
||||
} from '../reduction/neighborhood';
|
||||
import { SigmaGraph } from '../rendering/sigma/SigmaGraph';
|
||||
|
||||
interface SurfaceGraphCanvasProps {
|
||||
data: SurfaceMap;
|
||||
selectedNodeId: number | null;
|
||||
onSelectNode: (id: number) => void;
|
||||
}
|
||||
|
||||
export function SurfaceGraphCanvas({
|
||||
data,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: SurfaceGraphCanvasProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [neighborhoodOnly, setNeighborhoodOnly] = useState(false);
|
||||
const [radius, setRadius] = useState(2);
|
||||
|
||||
const fullGraph = useMemo(() => adaptSurfaceMap(data), [data]);
|
||||
const selectedNodeKey =
|
||||
selectedNodeId == null ? null : String(selectedNodeId);
|
||||
|
||||
const matches = useMemo(
|
||||
() => collectSearchMatches(fullGraph, searchQuery, 60),
|
||||
[fullGraph, searchQuery],
|
||||
);
|
||||
const matchKeys = useMemo(
|
||||
() => new Set(matches.map((node) => node.key)),
|
||||
[matches],
|
||||
);
|
||||
|
||||
const visibleGraph = useMemo(() => {
|
||||
if (!neighborhoodOnly || !selectedNodeKey) return fullGraph;
|
||||
return extractNeighborhoodSubgraph(fullGraph, selectedNodeKey, radius);
|
||||
}, [fullGraph, neighborhoodOnly, radius, selectedNodeKey]);
|
||||
|
||||
const { graph, isLoading, error } = useElkLayout(visibleGraph);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-state">Failed to compute the surface layout.</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!graph) {
|
||||
return <div className="loading">Preparing surface graph…</div>;
|
||||
}
|
||||
|
||||
const extras = (
|
||||
<>
|
||||
<label className="graph-toolbar-field">
|
||||
<span>Search</span>
|
||||
<input
|
||||
className="graph-toolbar-input"
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Route, label, or path"
|
||||
/>
|
||||
</label>
|
||||
<label className="graph-toolbar-field">
|
||||
<span>Match</span>
|
||||
<select
|
||||
className="graph-toolbar-select"
|
||||
value={selectedNodeKey ?? ''}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
if (!next) return;
|
||||
onSelectNode(Number(next));
|
||||
}}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{matches.map((match) => (
|
||||
<option key={match.key} value={match.key}>
|
||||
{match.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="graph-toolbar-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={neighborhoodOnly}
|
||||
onChange={(event) => setNeighborhoodOnly(event.target.checked)}
|
||||
/>
|
||||
<span>Neighbors only</span>
|
||||
</label>
|
||||
<label className="graph-toolbar-field graph-toolbar-field-compact">
|
||||
<span>Radius</span>
|
||||
<input
|
||||
className="graph-toolbar-range"
|
||||
type="range"
|
||||
min="1"
|
||||
max="4"
|
||||
step="1"
|
||||
value={radius}
|
||||
disabled={!neighborhoodOnly}
|
||||
onChange={(event) => setRadius(Number(event.target.value))}
|
||||
/>
|
||||
<strong>{radius}</strong>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SigmaGraph
|
||||
graph={graph}
|
||||
viewKind="surface"
|
||||
selectedNodeKey={selectedNodeKey}
|
||||
onNodeClick={(key) => onSelectNode(Number(key))}
|
||||
searchMatchKeys={matchKeys}
|
||||
toolbarExtras={extras}
|
||||
loading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,6 +39,14 @@ const PRESETS: Record<GraphViewKind, ElkLayoutPreset> = {
|
|||
padding: 32,
|
||||
edgeRouting: 'ORTHOGONAL',
|
||||
},
|
||||
surface: {
|
||||
direction: 'RIGHT',
|
||||
nodeSpacing: 44,
|
||||
layerSpacing: 156,
|
||||
edgeNodeSpacing: 28,
|
||||
padding: 36,
|
||||
edgeRouting: 'POLYLINE',
|
||||
},
|
||||
};
|
||||
|
||||
function measureNode(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ const CONFIG: Record<GraphViewKind, TextLayoutConfig> = {
|
|||
maxSecondaryLines: 2,
|
||||
maxSublabelLines: 1,
|
||||
},
|
||||
surface: {
|
||||
primaryChars: 32,
|
||||
secondaryChars: 32,
|
||||
maxPrimaryLines: 2,
|
||||
maxSecondaryLines: 2,
|
||||
maxSublabelLines: 1,
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,95 @@ function cfgNodeStyle(
|
|||
}
|
||||
}
|
||||
|
||||
function surfaceNodeStyle(type: string, palette: GraphThemePalette): NodeStyle {
|
||||
switch (type) {
|
||||
case 'EntryPoint':
|
||||
return {
|
||||
fill: palette.success,
|
||||
stroke: withAlpha(palette.success, 0.85),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.78),
|
||||
shape: 'double',
|
||||
strokeWidth: 1.8,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: withAlpha(palette.success, 0.75),
|
||||
};
|
||||
case 'DataStore':
|
||||
return {
|
||||
fill: palette.warning,
|
||||
stroke: withAlpha(palette.warning, 0.85),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.8),
|
||||
shape: 'rect',
|
||||
strokeWidth: 1.5,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: withAlpha(palette.warning, 0.76),
|
||||
};
|
||||
case 'ExternalService':
|
||||
return {
|
||||
fill: palette.accent,
|
||||
stroke: withAlpha(palette.accent, 0.82),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.8),
|
||||
shape: 'rect',
|
||||
strokeWidth: 1.5,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: palette.accentSoft,
|
||||
};
|
||||
case 'DangerousLocal':
|
||||
return {
|
||||
fill: palette.danger,
|
||||
stroke: withAlpha(palette.danger, 0.86),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.8),
|
||||
shape: 'terminal',
|
||||
strokeWidth: 1.7,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: withAlpha(palette.danger, 0.75),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
fill: withAlpha(palette.neutral, 0.92),
|
||||
stroke: withAlpha(palette.neutral, 0.8),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.78),
|
||||
shape: 'rect',
|
||||
strokeWidth: 1.2,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: withAlpha(palette.neutralSoft, 0.88),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function surfaceEdgeStyle(type: string, palette: GraphThemePalette): EdgeStyle {
|
||||
switch (type) {
|
||||
case 'calls':
|
||||
return {
|
||||
color: withAlpha(palette.textSecondary, 0.78),
|
||||
width: 1.4,
|
||||
dash: [],
|
||||
};
|
||||
case 'reads_from':
|
||||
return { color: palette.success, width: 1.5, dash: [] };
|
||||
case 'writes_to':
|
||||
return { color: palette.warning, width: 1.6, dash: [] };
|
||||
case 'talks_to':
|
||||
return { color: palette.accent, width: 1.4, dash: [] };
|
||||
case 'reaches':
|
||||
return { color: palette.danger, width: 1.7, dash: [] };
|
||||
case 'triggers':
|
||||
return { color: palette.success, width: 1.5, dash: [4, 3] };
|
||||
case 'auth_required_on':
|
||||
return { color: palette.textTertiary, width: 1.3, dash: [2, 4] };
|
||||
default:
|
||||
return {
|
||||
color: withAlpha(palette.textTertiary, 0.78),
|
||||
width: 1.3,
|
||||
dash: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function callGraphNodeStyle(
|
||||
palette: GraphThemePalette,
|
||||
metadata?: GraphMetadata,
|
||||
|
|
@ -221,9 +310,15 @@ export function getNodeStyle(
|
|||
metadata?: GraphMetadata,
|
||||
palette = FALLBACK_PALETTE,
|
||||
): NodeStyle {
|
||||
return graphKind === 'callgraph'
|
||||
? callGraphNodeStyle(palette, metadata)
|
||||
: cfgNodeStyle(type, palette, metadata);
|
||||
switch (graphKind) {
|
||||
case 'callgraph':
|
||||
return callGraphNodeStyle(palette, metadata);
|
||||
case 'surface':
|
||||
return surfaceNodeStyle(type, palette);
|
||||
case 'cfg':
|
||||
default:
|
||||
return cfgNodeStyle(type, palette, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
export function getEdgeStyle(
|
||||
|
|
@ -239,6 +334,10 @@ export function getEdgeStyle(
|
|||
};
|
||||
}
|
||||
|
||||
if (graphKind === 'surface') {
|
||||
return surfaceEdgeStyle(type, palette);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'True':
|
||||
return { color: palette.success, width: 1.8, dash: [] };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type GraphViewKind = 'callgraph' | 'cfg';
|
||||
export type GraphViewKind = 'callgraph' | 'cfg' | 'surface';
|
||||
|
||||
export interface GraphPoint {
|
||||
x: number;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface FindingsURLState {
|
|||
language: string;
|
||||
rule_id: string;
|
||||
status: string;
|
||||
verification: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ const FINDINGS_DEFAULTS: FindingsURLState = {
|
|||
language: '',
|
||||
rule_id: '',
|
||||
status: '',
|
||||
verification: '',
|
||||
search: '',
|
||||
};
|
||||
|
||||
|
|
@ -52,6 +54,7 @@ const FILTER_KEYS: ReadonlySet<string> = new Set([
|
|||
'language',
|
||||
'rule_id',
|
||||
'status',
|
||||
'verification',
|
||||
'search',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
useStartScan,
|
||||
type ScanMode,
|
||||
type EngineProfile,
|
||||
type VerifyBackend,
|
||||
type HardenProfile,
|
||||
type StartScanBody,
|
||||
} from '../api/mutations/scans';
|
||||
|
||||
|
|
@ -29,6 +31,18 @@ const PROFILE_HINTS: Record<EngineProfile, string> = {
|
|||
deep: 'Adds symex (cross-file + interproc) and demand-driven backwards taint. About 2 to 3x slower.',
|
||||
};
|
||||
|
||||
const BACKEND_HINTS: Record<VerifyBackend, string> = {
|
||||
auto: 'Use Docker when it fits, otherwise fall back to process.',
|
||||
docker: 'Require Docker-backed harness execution.',
|
||||
process: 'Unsafe local process backend for quick test runs.',
|
||||
firecracker: 'Use the Firecracker backend when available.',
|
||||
};
|
||||
|
||||
const HARDEN_HINTS: Record<HardenProfile, string> = {
|
||||
standard: 'Baseline process limits.',
|
||||
strict: 'Stricter process confinement when supported.',
|
||||
};
|
||||
|
||||
export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
||||
const { data: health } = useHealth();
|
||||
const startScan = useStartScan();
|
||||
|
|
@ -38,6 +52,9 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
|||
const [scanRoot, setScanRoot] = useState('');
|
||||
const [mode, setMode] = useState<ScanMode>('full');
|
||||
const [engineProfile, setEngineProfile] = useState<EngineProfile>('balanced');
|
||||
const [noVerify, setNoVerify] = useState(false);
|
||||
const [verifyBackend, setVerifyBackend] = useState<VerifyBackend>('auto');
|
||||
const [hardenProfile, setHardenProfile] = useState<HardenProfile>('standard');
|
||||
|
||||
const handleStart = async () => {
|
||||
const root = scanRoot.trim();
|
||||
|
|
@ -45,6 +62,12 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
|||
if (root && root !== defaultRoot) body.scan_root = root;
|
||||
if (mode !== 'full') body.mode = mode;
|
||||
body.engine_profile = engineProfile;
|
||||
if (noVerify) {
|
||||
body.verify = false;
|
||||
} else {
|
||||
body.verify_backend = verifyBackend;
|
||||
body.harden_profile = hardenProfile;
|
||||
}
|
||||
const payload = Object.keys(body).length ? body : undefined;
|
||||
try {
|
||||
await startScan.mutateAsync(payload);
|
||||
|
|
@ -105,6 +128,54 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
|||
</select>
|
||||
<span className="form-hint">{PROFILE_HINTS[engineProfile]}</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Dynamic Verification</label>
|
||||
<div className="toggle-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="new-scan-no-verify"
|
||||
checked={noVerify}
|
||||
onChange={(e) => setNoVerify(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="new-scan-no-verify">
|
||||
Skip dynamic verification for this scan.
|
||||
</label>
|
||||
</div>
|
||||
<span className="form-hint">
|
||||
Verification runs by default on Medium and High confidence
|
||||
findings. Check to skip and get a fast static-only result.
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Verification Backend</label>
|
||||
<select
|
||||
value={verifyBackend}
|
||||
disabled={noVerify}
|
||||
onChange={(e) =>
|
||||
setVerifyBackend(e.target.value as VerifyBackend)
|
||||
}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="docker">Docker</option>
|
||||
<option value="process">Process (unsafe)</option>
|
||||
<option value="firecracker">Firecracker</option>
|
||||
</select>
|
||||
<span className="form-hint">{BACKEND_HINTS[verifyBackend]}</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Process Hardening</label>
|
||||
<select
|
||||
value={hardenProfile}
|
||||
disabled={noVerify || verifyBackend !== 'process'}
|
||||
onChange={(e) =>
|
||||
setHardenProfile(e.target.value as HardenProfile)
|
||||
}
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="strict">Strict</option>
|
||||
</select>
|
||||
<span className="form-hint">{HARDEN_HINTS[hardenProfile]}</span>
|
||||
</div>
|
||||
<div className="scan-modal-actions">
|
||||
<button className="btn btn-sm" onClick={onClose}>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { escapeHtml, highlightSyntax } from '../utils/syntaxHighlight';
|
|||
import { parseNoteText } from '../utils/parseNote';
|
||||
import { findingToMarkdown } from '../utils/findingMarkdown';
|
||||
import { CopyMarkdownButton } from '../components/CopyMarkdownButton';
|
||||
import { VerdictBadge } from '../components/VerdictBadge';
|
||||
import { Dropdown, DropdownItem } from '../components/ui/Dropdown';
|
||||
import { CodeViewerModal } from '../modals/CodeViewerModal';
|
||||
import type {
|
||||
|
|
@ -16,6 +17,7 @@ import type {
|
|||
FlowStep,
|
||||
SpanEvidence,
|
||||
RelatedFindingView,
|
||||
VerifyResult,
|
||||
} from '../api/types';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -701,6 +703,107 @@ function HowToFix({ finding }: { finding: FindingView }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Dynamic Verification Panel ──────────────────────────────────────────────
|
||||
|
||||
export function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const attempts = verdict.attempts ?? [];
|
||||
// The repro bundle is keyed by spec_hash (not finding_id) inside the Nyx
|
||||
// cache. Rather than showing a path that may not match, surface the CLI
|
||||
// command that locates and opens the bundle regardless of the hash.
|
||||
const reproCmd = `nyx repro --finding ${verdict.finding_id}`;
|
||||
|
||||
const copyCmd = () => {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(reproCmd).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dynamic-verdict-section">
|
||||
<div className="dynamic-verdict-badge-row">
|
||||
<VerdictBadge verdict={verdict} />
|
||||
{verdict.toolchain_match && (
|
||||
<span
|
||||
className="dynamic-toolchain-match"
|
||||
title={`Toolchain match: ${verdict.toolchain_match}`}
|
||||
>
|
||||
{verdict.toolchain_match === 'exact'
|
||||
? 'exact toolchain'
|
||||
: 'approximate toolchain'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{verdict.status === 'Confirmed' && (
|
||||
<div className="repro-panel" data-testid="repro-panel">
|
||||
<div className="repro-cmd-row">
|
||||
<span className="repro-label">Reproduce:</span>
|
||||
<code className="repro-cmd">{reproCmd}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm repro-copy-btn"
|
||||
onClick={copyCmd}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(verdict.reason || verdict.inconclusive_reason || verdict.detail) && (
|
||||
<div className="dynamic-verdict-detail">
|
||||
{verdict.reason && (
|
||||
<div>
|
||||
<strong>Reason:</strong> {verdict.reason}
|
||||
</div>
|
||||
)}
|
||||
{verdict.inconclusive_reason && (
|
||||
<div>
|
||||
<strong>Inconclusive reason:</strong>{' '}
|
||||
{verdict.inconclusive_reason}
|
||||
</div>
|
||||
)}
|
||||
{verdict.detail && (
|
||||
<div className="dynamic-verdict-detail-text">{verdict.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attempts.length > 0 && (
|
||||
<div className="dynamic-attempts">
|
||||
<strong>Payload attempts:</strong>
|
||||
<ul className="dynamic-attempt-list">
|
||||
{attempts.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`attempt-row ${a.triggered ? 'triggered' : ''}`}
|
||||
>
|
||||
<code>{a.payload_label}</code>
|
||||
<span className="attempt-outcome">
|
||||
{a.triggered
|
||||
? 'triggered'
|
||||
: a.timed_out
|
||||
? 'timeout'
|
||||
: 'no hit'}
|
||||
</span>
|
||||
{a.exit_code != null && (
|
||||
<span className="attempt-exit-code">exit {a.exit_code}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status Control ──────────────────────────────────────────────────────────
|
||||
|
||||
function StatusControl({
|
||||
|
|
@ -861,6 +964,7 @@ export function FindingDetailPage() {
|
|||
|
||||
const f = finding;
|
||||
const evidence = f.evidence;
|
||||
const dynamicVerdict = evidence?.dynamic_verdict ?? f.dynamic_verdict;
|
||||
const isState = isStateFinding(f);
|
||||
const hasWhySection =
|
||||
f.message ||
|
||||
|
|
@ -1017,6 +1121,13 @@ export function FindingDetailPage() {
|
|||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Dynamic Verification */}
|
||||
{dynamicVerdict && (
|
||||
<CollapsibleSection title="Dynamic Verification">
|
||||
<DynamicVerdictSection verdict={dynamicVerdict} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Code Preview */}
|
||||
{hasCode && (
|
||||
<CollapsibleSection title="Code Preview" defaultOpen={false}>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Dropdown, DropdownItem } from '../components/ui/Dropdown';
|
|||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { CopyMarkdownButton } from '../components/CopyMarkdownButton';
|
||||
import { VerdictBadge } from '../components/VerdictBadge';
|
||||
import { truncPath } from '../utils/truncPath';
|
||||
import { findingsToMarkdown } from '../utils/findingMarkdown';
|
||||
import { ApiError } from '../api/client';
|
||||
|
|
@ -28,6 +29,12 @@ function formatTriageState(state: string): string {
|
|||
return (state || 'open').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatVerificationStatus(status: string): string {
|
||||
if (status === 'NotConfirmed') return 'Not confirmed';
|
||||
if (status === 'PartiallyConfirmed') return 'Partially confirmed';
|
||||
return status || 'Unverified';
|
||||
}
|
||||
|
||||
// ── Filter Bar ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface FilterSelectProps {
|
||||
|
|
@ -36,6 +43,7 @@ interface FilterSelectProps {
|
|||
values: string[] | undefined;
|
||||
current: string;
|
||||
onChange: (value: string) => void;
|
||||
formatValue?: (value: string) => string;
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
|
|
@ -44,6 +52,7 @@ function FilterSelect({
|
|||
values,
|
||||
current,
|
||||
onChange,
|
||||
formatValue,
|
||||
}: FilterSelectProps) {
|
||||
if (!values || values.length === 0) return null;
|
||||
return (
|
||||
|
|
@ -51,7 +60,7 @@ function FilterSelect({
|
|||
<option value="">All {label}</option>
|
||||
{values.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
{formatValue ? formatValue(v) : v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -321,6 +330,7 @@ export function FindingsPage() {
|
|||
language: state.language || undefined,
|
||||
rule_id: state.rule_id || undefined,
|
||||
status: state.status || undefined,
|
||||
verification: state.verification || undefined,
|
||||
search: state.search || undefined,
|
||||
}),
|
||||
[state],
|
||||
|
|
@ -620,6 +630,14 @@ export function FindingsPage() {
|
|||
current={state.status}
|
||||
onChange={(v) => handleFilterChange('status', v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="filter-verification"
|
||||
label="Verification"
|
||||
values={filters?.verification_statuses}
|
||||
current={state.verification}
|
||||
onChange={(v) => handleFilterChange('verification', v)}
|
||||
formatValue={formatVerificationStatus}
|
||||
/>
|
||||
{hasActiveFilters && (
|
||||
<button className="btn btn-sm btn-clear" onClick={resetFilters}>
|
||||
Clear All
|
||||
|
|
@ -711,6 +729,7 @@ export function FindingsPage() {
|
|||
currentDir={state.sort_dir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<th>Verified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -760,6 +779,14 @@ export function FindingsPage() {
|
|||
{formatTriageState(f.triage_state || f.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<VerdictBadge
|
||||
verdict={
|
||||
f.dynamic_verdict ?? f.evidence?.dynamic_verdict
|
||||
}
|
||||
compact
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
CompareResponse,
|
||||
ComparedFinding,
|
||||
ChangedFinding,
|
||||
VerdictTransition,
|
||||
} from '../api/types';
|
||||
|
||||
function truncPath(p?: string, max = 50): string {
|
||||
|
|
@ -273,7 +274,115 @@ function CompareByGroup({
|
|||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CompareTab = 'status' | 'rule' | 'file';
|
||||
// ── Verdict Diff Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
const TRANSITION_ORDER: VerdictTransition[] = [
|
||||
'FlippedConfirmed',
|
||||
'Regressed',
|
||||
'New',
|
||||
'FlippedNotConfirmed',
|
||||
'Resolved',
|
||||
'Unchanged',
|
||||
];
|
||||
|
||||
const TRANSITION_LABELS: Record<VerdictTransition, string> = {
|
||||
FlippedConfirmed: 'Flipped Confirmed',
|
||||
Regressed: 'Regressed',
|
||||
New: 'New',
|
||||
FlippedNotConfirmed: 'Flipped Not Confirmed',
|
||||
Resolved: 'Resolved',
|
||||
Unchanged: 'Unchanged',
|
||||
};
|
||||
|
||||
const TRANSITION_ROW_CLS: Record<VerdictTransition, string> = {
|
||||
FlippedConfirmed: 'compare-finding-row--new',
|
||||
Regressed: 'compare-finding-row--new',
|
||||
New: 'compare-finding-row--new',
|
||||
FlippedNotConfirmed: 'compare-finding-row--changed',
|
||||
Resolved: 'compare-finding-row--fixed',
|
||||
Unchanged: 'compare-finding-row--unchanged',
|
||||
};
|
||||
|
||||
function VerdictDiffSection({ data }: { data: CompareResponse }) {
|
||||
const entries = data.verdict_diff;
|
||||
if (!entries || entries.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{ color: 'var(--text-secondary)', padding: 'var(--space-4)' }}
|
||||
>
|
||||
No verdict-level transitions. Both scans share no findings with stable
|
||||
hashes.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const grouped: Partial<Record<VerdictTransition, typeof entries>> = {};
|
||||
for (const e of entries) {
|
||||
if (!grouped[e.transition]) grouped[e.transition] = [];
|
||||
grouped[e.transition]!.push(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{TRANSITION_ORDER.map((t) => {
|
||||
const items = grouped[t];
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<CollapsibleSection
|
||||
key={t}
|
||||
sectionKey={t}
|
||||
defaultCollapsed={t === 'Unchanged'}
|
||||
headerContent={
|
||||
<>
|
||||
<span
|
||||
className={`compare-finding-row ${TRANSITION_ROW_CLS[t]}`}
|
||||
style={{
|
||||
padding: '0 var(--space-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{TRANSITION_LABELS[t]}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'var(--space-2)' }}>
|
||||
({items.length})
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{items.map((e, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`compare-finding-row ${TRANSITION_ROW_CLS[t]}`}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>
|
||||
{e.path}:{e.line}
|
||||
</span>
|
||||
<span>{e.rule_id}</span>
|
||||
{e.baseline_status && (
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
{e.baseline_status}
|
||||
</span>
|
||||
)}
|
||||
{e.current_status && (
|
||||
<>
|
||||
<span className="delta-arrow">→</span>
|
||||
<span>{e.current_status}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type CompareTab = 'status' | 'rule' | 'file' | 'verdict';
|
||||
|
||||
export function ScanComparePage() {
|
||||
usePageTitle('Compare scans');
|
||||
|
|
@ -403,6 +512,12 @@ export function ScanComparePage() {
|
|||
>
|
||||
By File
|
||||
</button>
|
||||
<button
|
||||
className={`scan-detail-tab ${activeTab === 'verdict' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('verdict')}
|
||||
>
|
||||
Verdict Diff
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="compare-tab-content">
|
||||
|
|
@ -413,6 +528,7 @@ export function ScanComparePage() {
|
|||
{activeTab === 'file' && (
|
||||
<CompareByGroup data={data} groupField="path" />
|
||||
)}
|
||||
{activeTab === 'verdict' && <VerdictDiffSection data={data} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
314
frontend/src/pages/SurfacePage.tsx
Normal file
314
frontend/src/pages/SurfacePage.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useSurfaceMap } from '../api/queries/surface';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { EmptyState } from '../components/ui/EmptyState';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import { SurfaceGraphCanvas } from '../graph/components/SurfaceGraphCanvas';
|
||||
import type {
|
||||
SurfaceEdge,
|
||||
SurfaceEdgeKind,
|
||||
SurfaceMap,
|
||||
SurfaceNode,
|
||||
} from '../api/types';
|
||||
|
||||
const EDGE_KIND_LABELS: Record<SurfaceEdgeKind, string> = {
|
||||
calls: 'Calls',
|
||||
reads_from: 'Reads',
|
||||
writes_to: 'Writes',
|
||||
talks_to: 'Talks to',
|
||||
reaches: 'Reaches',
|
||||
triggers: 'Triggers',
|
||||
auth_required_on: 'Auth required',
|
||||
};
|
||||
|
||||
const NODE_KIND_COLORS: Record<SurfaceNode['node'], string> = {
|
||||
entry_point: 'var(--accent)',
|
||||
data_store: 'var(--sev-medium)',
|
||||
external_service: 'var(--sev-low)',
|
||||
dangerous_local: 'var(--sev-high)',
|
||||
};
|
||||
|
||||
function nodeTitle(node: SurfaceNode): string {
|
||||
switch (node.node) {
|
||||
case 'entry_point':
|
||||
return `${node.method} ${node.route}`;
|
||||
case 'data_store':
|
||||
return `${node.kind}: ${node.label}`;
|
||||
case 'external_service':
|
||||
return `${node.kind}: ${node.label}`;
|
||||
case 'dangerous_local':
|
||||
return node.function_name;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeSubtitle(node: SurfaceNode): string {
|
||||
switch (node.node) {
|
||||
case 'entry_point':
|
||||
return `${node.framework} → ${node.handler_name}`;
|
||||
case 'data_store':
|
||||
return 'Data store';
|
||||
case 'external_service':
|
||||
return 'External service';
|
||||
case 'dangerous_local':
|
||||
return `cap=0x${node.cap_bits.toString(16)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeLocation(node: SurfaceNode): string {
|
||||
const loc =
|
||||
node.node === 'entry_point' ? node.handler_location : node.location;
|
||||
return `${loc.file}:${loc.line}`;
|
||||
}
|
||||
|
||||
function NodeCard({
|
||||
node,
|
||||
index,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
node: SurfaceNode;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const color = NODE_KIND_COLORS[node.node];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`surface-node-card${selected ? ' selected' : ''}`}
|
||||
style={{
|
||||
border: `1px solid ${selected ? color : 'var(--border)'}`,
|
||||
borderLeft: `4px solid ${color}`,
|
||||
background: selected ? 'var(--surface-2)' : 'var(--surface-1)',
|
||||
}}
|
||||
>
|
||||
<span className="surface-node-card-meta">
|
||||
#{index} · {node.node.replace('_', ' ')}
|
||||
{node.node === 'entry_point' && node.auth_required ? ' · auth' : ''}
|
||||
</span>
|
||||
<span className="surface-node-card-title">{nodeTitle(node)}</span>
|
||||
<span className="surface-node-card-subtitle">{nodeSubtitle(node)}</span>
|
||||
<code className="surface-node-card-loc">{nodeLocation(node)}</code>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function summarize(map: SurfaceMap): {
|
||||
entries: number;
|
||||
stores: number;
|
||||
externals: number;
|
||||
dangerous: number;
|
||||
edgeKinds: Record<string, number>;
|
||||
} {
|
||||
let entries = 0;
|
||||
let stores = 0;
|
||||
let externals = 0;
|
||||
let dangerous = 0;
|
||||
for (const n of map.nodes) {
|
||||
if (n.node === 'entry_point') entries++;
|
||||
else if (n.node === 'data_store') stores++;
|
||||
else if (n.node === 'external_service') externals++;
|
||||
else if (n.node === 'dangerous_local') dangerous++;
|
||||
}
|
||||
const edgeKinds: Record<string, number> = {};
|
||||
for (const e of map.edges) {
|
||||
edgeKinds[e.kind] = (edgeKinds[e.kind] ?? 0) + 1;
|
||||
}
|
||||
return { entries, stores, externals, dangerous, edgeKinds };
|
||||
}
|
||||
|
||||
function NeighborList({
|
||||
map,
|
||||
index,
|
||||
}: {
|
||||
map: SurfaceMap;
|
||||
index: number | null;
|
||||
}) {
|
||||
if (index === null) {
|
||||
return (
|
||||
<p className="surface-neighbor-empty">
|
||||
Select a node on the left to see its neighbours.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const node = map.nodes[index];
|
||||
if (!node) return null;
|
||||
|
||||
const outgoing: SurfaceEdge[] = map.edges.filter((e) => e.from === index);
|
||||
const incoming: SurfaceEdge[] = map.edges.filter((e) => e.to === index);
|
||||
|
||||
const renderEdges = (edges: SurfaceEdge[], direction: 'in' | 'out') => {
|
||||
if (edges.length === 0) {
|
||||
return (
|
||||
<p className="surface-neighbor-empty">
|
||||
(no {direction === 'in' ? 'inbound' : 'outbound'} edges)
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="surface-neighbor-edges">
|
||||
{edges.map((e, i) => {
|
||||
const otherIdx = direction === 'in' ? e.from : e.to;
|
||||
const other = map.nodes[otherIdx];
|
||||
if (!other) return null;
|
||||
return (
|
||||
<li key={`${direction}-${i}`} className="surface-neighbor-edge">
|
||||
<span className="surface-neighbor-edge-kind">
|
||||
{EDGE_KIND_LABELS[e.kind]}
|
||||
</span>
|
||||
<span>
|
||||
{direction === 'in' ? '←' : '→'}{' '}
|
||||
<strong>{nodeTitle(other)}</strong>
|
||||
</span>
|
||||
<code className="surface-neighbor-edge-loc">
|
||||
{nodeLocation(other)}
|
||||
</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="surface-neighbor-title">{nodeTitle(node)}</h3>
|
||||
<p className="surface-neighbor-subtitle">
|
||||
{nodeSubtitle(node)} — <code>{nodeLocation(node)}</code>
|
||||
</p>
|
||||
<h4>Outbound</h4>
|
||||
{renderEdges(outgoing, 'out')}
|
||||
<h4>Inbound</h4>
|
||||
{renderEdges(incoming, 'in')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type NodeKindFilter = 'all' | SurfaceNode['node'];
|
||||
type SurfaceViewMode = 'list' | 'graph';
|
||||
|
||||
export function SurfacePage() {
|
||||
usePageTitle('Surface');
|
||||
const { data, isLoading, error } = useSurfaceMap();
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState<NodeKindFilter>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<SurfaceViewMode>('list');
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!data) return [] as Array<{ node: SurfaceNode; index: number }>;
|
||||
const q = query.trim().toLowerCase();
|
||||
return data.nodes
|
||||
.map((node, index) => ({ node, index }))
|
||||
.filter(({ node }) => filter === 'all' || node.node === filter)
|
||||
.filter(({ node }) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
nodeTitle(node).toLowerCase().includes(q) ||
|
||||
nodeSubtitle(node).toLowerCase().includes(q) ||
|
||||
nodeLocation(node).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [data, filter, query]);
|
||||
|
||||
if (isLoading) return <LoadingState message="Loading surface map..." />;
|
||||
if (error) return <ErrorState message={error.message} />;
|
||||
if (!data || data.nodes.length === 0) {
|
||||
return (
|
||||
<EmptyState message="No surface yet. Run an indexed scan (`nyx scan`) to populate the attack-surface map, or invoke `nyx surface` against the project." />
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarize(data);
|
||||
|
||||
return (
|
||||
<div className="page-content">
|
||||
<header className="surface-header">
|
||||
<h1>Attack surface</h1>
|
||||
<span className="surface-header-summary">
|
||||
{summary.entries} entry-points · {summary.stores} stores ·{' '}
|
||||
{summary.externals} services · {summary.dangerous} dangerous locals ·{' '}
|
||||
{data.edges.length} edges
|
||||
</span>
|
||||
</header>
|
||||
<div className="surface-filter-row">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Filter by name, label, or path"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="surface-filter-input"
|
||||
disabled={viewMode === 'graph'}
|
||||
/>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as NodeKindFilter)}
|
||||
className="surface-filter-select"
|
||||
disabled={viewMode === 'graph'}
|
||||
>
|
||||
<option value="all">All node kinds</option>
|
||||
<option value="entry_point">Entry points</option>
|
||||
<option value="data_store">Data stores</option>
|
||||
<option value="external_service">External services</option>
|
||||
<option value="dangerous_local">Dangerous locals</option>
|
||||
</select>
|
||||
<div
|
||||
className="surface-view-toggle"
|
||||
role="tablist"
|
||||
aria-label="Surface view"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'list'}
|
||||
className={`surface-view-toggle-button${viewMode === 'list' ? ' selected' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'graph'}
|
||||
className={`surface-view-toggle-button${viewMode === 'graph' ? ' selected' : ''}`}
|
||||
onClick={() => setViewMode('graph')}
|
||||
>
|
||||
Graph
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface-grid">
|
||||
{viewMode === 'list' ? (
|
||||
<div className="surface-node-list">
|
||||
{visible.length === 0 ? (
|
||||
<p className="surface-node-list-empty">No nodes match.</p>
|
||||
) : (
|
||||
visible.map(({ node, index }) => (
|
||||
<NodeCard
|
||||
key={index}
|
||||
node={node}
|
||||
index={index}
|
||||
selected={selected === index}
|
||||
onClick={() => setSelected(index)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="surface-graph-frame">
|
||||
<SurfaceGraphCanvas
|
||||
data={data}
|
||||
selectedNodeId={selected}
|
||||
onSelectNode={(id) => setSelected(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<aside className="surface-sidebar">
|
||||
<NeighborList map={data} index={selected} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -177,6 +177,165 @@ a:hover {
|
|||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.target-switcher {
|
||||
position: relative;
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
}
|
||||
.target-trigger,
|
||||
.target-option,
|
||||
.target-add-button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.target-trigger {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr) 12px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.target-trigger:hover,
|
||||
.target-trigger[aria-expanded='true'] {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.target-avatar,
|
||||
.target-option-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
font-weight: var(--weight-semibold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.target-trigger-copy,
|
||||
.target-option-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.target-name,
|
||||
.target-option-name {
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.target-path,
|
||||
.target-option-path {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.7rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.target-caret {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1.5px solid var(--text-tertiary);
|
||||
border-bottom: 1.5px solid var(--text-tertiary);
|
||||
transform: rotate(45deg) translateY(-2px);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
.target-caret.open {
|
||||
transform: rotate(225deg) translateY(-2px);
|
||||
}
|
||||
.target-menu {
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
right: var(--space-3);
|
||||
top: calc(100% - var(--space-1));
|
||||
z-index: 30;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.target-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.target-option {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 5px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.target-option:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.target-option.active {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
.target-option:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.target-option-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.target-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 30px;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.target-add-form input {
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 5px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.target-add-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 1rem;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.target-add-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.target-error {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--sev-high);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
padding: var(--space-3) var(--space-3);
|
||||
|
|
@ -2504,6 +2663,143 @@ tr.selected td {
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Finding Detail: dynamic verification ─────────────────────────── */
|
||||
.badge-dyn-confirmed {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge-dyn-partiallyconfirmed {
|
||||
background: var(--conf-medium-bg);
|
||||
color: var(--conf-medium);
|
||||
}
|
||||
.badge-dyn-notconfirmed {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.badge-dyn-inconclusive {
|
||||
background: var(--sev-medium-bg);
|
||||
color: var(--sev-medium);
|
||||
}
|
||||
.badge-dyn-unsupported {
|
||||
background: var(--conf-low-bg);
|
||||
color: var(--conf-low);
|
||||
}
|
||||
.dynamic-verdict-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.dynamic-verdict-badge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.dynamic-toolchain-match {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.repro-panel,
|
||||
.dynamic-verdict-detail,
|
||||
.dynamic-attempts {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.repro-cmd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.repro-label,
|
||||
.dynamic-attempts > strong,
|
||||
.dynamic-verdict-detail strong {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.repro-cmd {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 4px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--terminal-bg);
|
||||
color: var(--terminal-text);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.repro-copy-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.dynamic-verdict-detail {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.dynamic-verdict-detail-text {
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dynamic-attempt-list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
.attempt-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.attempt-row.triggered {
|
||||
border-color: color-mix(in srgb, var(--success) 35%, var(--border));
|
||||
background: var(--success-bg);
|
||||
}
|
||||
.attempt-row code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.attempt-outcome,
|
||||
.attempt-exit-code {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attempt-row.triggered .attempt-outcome {
|
||||
color: var(--success);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.attempt-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
.attempt-outcome,
|
||||
.attempt-exit-code {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Code Viewer Modal ────────────────────────────────────────────── */
|
||||
.code-modal-overlay {
|
||||
position: fixed;
|
||||
|
|
@ -8793,3 +9089,153 @@ input[type='checkbox'] {
|
|||
[data-theme='light'] .code-modal-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* SurfacePage */
|
||||
.surface-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.surface-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.surface-header-summary {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.surface-filter-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.surface-filter-input {
|
||||
flex: 1 1 220px;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.surface-filter-select {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.surface-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.4fr);
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.surface-node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.surface-node-list-empty {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.surface-sidebar {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.surface-node-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-2);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.surface-node-card-meta {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.surface-node-card-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.surface-node-card-subtitle {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.surface-node-card-loc {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.surface-neighbor-empty {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.surface-neighbor-title {
|
||||
margin-top: 0;
|
||||
}
|
||||
.surface-neighbor-subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0;
|
||||
}
|
||||
.surface-neighbor-edges {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.surface-neighbor-edge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.surface-neighbor-edge-kind {
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.surface-neighbor-edge-loc {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.surface-view-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-1);
|
||||
overflow: hidden;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.surface-view-toggle-button {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.surface-view-toggle-button:not(:last-child) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.surface-view-toggle-button.selected {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.surface-graph-frame {
|
||||
position: relative;
|
||||
min-height: 70vh;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--surface-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
154
frontend/src/test/components/dynamicVerdictSection.test.tsx
Normal file
154
frontend/src/test/components/dynamicVerdictSection.test.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { DynamicVerdictSection } from '@/pages/FindingDetailPage';
|
||||
import type { VerifyResult } from '@/api/types';
|
||||
|
||||
function makeVerdict(
|
||||
status: VerifyResult['status'],
|
||||
extras: Partial<VerifyResult> = {},
|
||||
): VerifyResult {
|
||||
return {
|
||||
finding_id: 'test-finding-id-abc',
|
||||
status,
|
||||
attempts: [],
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock navigator.clipboard before each test.
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('DynamicVerdictSection', () => {
|
||||
it('renders Confirmed badge', () => {
|
||||
render(
|
||||
<DynamicVerdictSection
|
||||
verdict={makeVerdict('Confirmed', {
|
||||
triggered_payload: 'sqli-tautology',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('verdict-badge-confirmed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NotConfirmed badge', () => {
|
||||
render(<DynamicVerdictSection verdict={makeVerdict('NotConfirmed')} />);
|
||||
expect(
|
||||
screen.getByTestId('verdict-badge-notconfirmed'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PartiallyConfirmed badge', () => {
|
||||
render(
|
||||
<DynamicVerdictSection
|
||||
verdict={makeVerdict('PartiallyConfirmed', {
|
||||
detail: 'sink reached but exploit chain did not complete',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('verdict-badge-partiallyconfirmed'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not crash when the API omits an empty attempts array', () => {
|
||||
render(
|
||||
<DynamicVerdictSection
|
||||
verdict={{ finding_id: 'no-attempts', status: 'Confirmed' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('verdict-badge-confirmed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Unsupported badge', () => {
|
||||
render(
|
||||
<DynamicVerdictSection
|
||||
verdict={makeVerdict('Unsupported', { reason: 'NoPayloadsForCap' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('verdict-badge-unsupported')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Inconclusive badge', () => {
|
||||
render(
|
||||
<DynamicVerdictSection
|
||||
verdict={makeVerdict('Inconclusive', {
|
||||
inconclusive_reason: 'BuildFailed',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('verdict-badge-inconclusive'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows repro panel only for Confirmed status', () => {
|
||||
const { unmount } = render(
|
||||
<DynamicVerdictSection verdict={makeVerdict('Confirmed')} />,
|
||||
);
|
||||
expect(screen.getByTestId('repro-panel')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
for (const status of [
|
||||
'PartiallyConfirmed',
|
||||
'NotConfirmed',
|
||||
'Unsupported',
|
||||
'Inconclusive',
|
||||
] as const) {
|
||||
const { unmount: u } = render(
|
||||
<DynamicVerdictSection verdict={makeVerdict(status)} />,
|
||||
);
|
||||
expect(screen.queryByTestId('repro-panel')).toBeNull();
|
||||
u();
|
||||
}
|
||||
});
|
||||
|
||||
it('repro-panel contains the finding_id in the CLI command', () => {
|
||||
render(
|
||||
<DynamicVerdictSection
|
||||
verdict={makeVerdict('Confirmed', { finding_id: 'cafecafe12345678' })}
|
||||
/>,
|
||||
);
|
||||
const panel = screen.getByTestId('repro-panel');
|
||||
expect(panel.textContent).toContain('cafecafe12345678');
|
||||
expect(panel.textContent).toContain('nyx repro');
|
||||
});
|
||||
|
||||
it('Copy button triggers clipboard writeText with the repro command', async () => {
|
||||
const findingId = 'test-finding-id-abc';
|
||||
render(<DynamicVerdictSection verdict={makeVerdict('Confirmed')} />);
|
||||
|
||||
const copyBtn = screen.getByRole('button', { name: /copy/i });
|
||||
fireEvent.click(copyBtn);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledOnce();
|
||||
const calledWith = (
|
||||
navigator.clipboard.writeText as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0][0] as string;
|
||||
expect(calledWith).toContain(findingId);
|
||||
expect(calledWith).toContain('nyx repro');
|
||||
});
|
||||
|
||||
it('shows exact toolchain match label when toolchain_match is exact', () => {
|
||||
render(
|
||||
<DynamicVerdictSection
|
||||
verdict={makeVerdict('Confirmed', { toolchain_match: 'exact' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('exact toolchain')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows approximate toolchain match label when toolchain_match is drift', () => {
|
||||
render(
|
||||
<DynamicVerdictSection
|
||||
verdict={makeVerdict('Confirmed', { toolchain_match: 'drift' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('approximate toolchain')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
144
frontend/src/test/components/verdictBadge.test.tsx
Normal file
144
frontend/src/test/components/verdictBadge.test.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { VerdictBadge } from '@/components/VerdictBadge';
|
||||
import type { VerifyResult } from '@/api/types';
|
||||
|
||||
function makeVerdict(
|
||||
status: VerifyResult['status'],
|
||||
extras: Partial<VerifyResult> = {},
|
||||
): VerifyResult {
|
||||
return {
|
||||
finding_id: 'test-finding-id',
|
||||
status,
|
||||
attempts: [],
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
describe('VerdictBadge', () => {
|
||||
it('renders dash when verdict is undefined', () => {
|
||||
render(<VerdictBadge verdict={undefined} />);
|
||||
expect(screen.getByText('-')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Confirmed badge with flame and correct class', () => {
|
||||
render(
|
||||
<VerdictBadge
|
||||
verdict={makeVerdict('Confirmed', {
|
||||
triggered_payload: 'sqli-tautology',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByTestId('verdict-badge-confirmed');
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.className).toContain('badge-dyn-confirmed');
|
||||
expect(badge.textContent).toContain('🔥');
|
||||
});
|
||||
|
||||
it('renders PartiallyConfirmed badge with amber class and no flame', () => {
|
||||
render(
|
||||
<VerdictBadge
|
||||
verdict={makeVerdict('PartiallyConfirmed', {
|
||||
detail:
|
||||
'sink-reachability probe fired but the oracle marker was not observed',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByTestId('verdict-badge-partiallyconfirmed');
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.className).toContain('badge-dyn-partiallyconfirmed');
|
||||
expect(badge.textContent).not.toContain('🔥');
|
||||
expect(badge.getAttribute('title')).toContain('sink reached');
|
||||
});
|
||||
|
||||
it('renders NotConfirmed badge with correct class', () => {
|
||||
render(<VerdictBadge verdict={makeVerdict('NotConfirmed')} />);
|
||||
const badge = screen.getByTestId('verdict-badge-notconfirmed');
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.className).toContain('badge-dyn-notconfirmed');
|
||||
expect(badge.textContent).not.toContain('🔥');
|
||||
});
|
||||
|
||||
it('renders when attempts are omitted by the API', () => {
|
||||
render(
|
||||
<VerdictBadge
|
||||
verdict={{ finding_id: 'test-finding-id', status: 'NotConfirmed' }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('verdict-badge-notconfirmed'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Unsupported badge with correct class', () => {
|
||||
render(
|
||||
<VerdictBadge
|
||||
verdict={makeVerdict('Unsupported', { reason: 'NoPayloadsForCap' })}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByTestId('verdict-badge-unsupported');
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.className).toContain('badge-dyn-unsupported');
|
||||
});
|
||||
|
||||
it('renders Inconclusive badge with amber class', () => {
|
||||
render(
|
||||
<VerdictBadge
|
||||
verdict={makeVerdict('Inconclusive', {
|
||||
inconclusive_reason: 'BuildFailed',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByTestId('verdict-badge-inconclusive');
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.className).toContain('badge-dyn-inconclusive');
|
||||
});
|
||||
|
||||
it('tooltip contains payload for Confirmed', () => {
|
||||
render(
|
||||
<VerdictBadge
|
||||
verdict={makeVerdict('Confirmed', {
|
||||
triggered_payload: 'sqli-payload',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByTestId('verdict-badge-confirmed');
|
||||
expect(badge.getAttribute('title')).toContain('sqli-payload');
|
||||
});
|
||||
|
||||
it('tooltip contains reason for Unsupported', () => {
|
||||
render(
|
||||
<VerdictBadge
|
||||
verdict={makeVerdict('Unsupported', { reason: 'ConfidenceTooLow' })}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByTestId('verdict-badge-unsupported');
|
||||
expect(badge.getAttribute('title')).toContain('ConfidenceTooLow');
|
||||
});
|
||||
|
||||
it('compact mode renders single character', () => {
|
||||
render(<VerdictBadge verdict={makeVerdict('Confirmed')} compact />);
|
||||
const badge = screen.getByTestId('verdict-badge-confirmed');
|
||||
// Compact: first char of status + flame emoji
|
||||
expect(badge.textContent?.replace('🔥 ', '')).toBe('C');
|
||||
});
|
||||
|
||||
it('renders all five VerifyStatus variants without crashing', () => {
|
||||
const statuses: VerifyResult['status'][] = [
|
||||
'Confirmed',
|
||||
'PartiallyConfirmed',
|
||||
'NotConfirmed',
|
||||
'Unsupported',
|
||||
'Inconclusive',
|
||||
];
|
||||
for (const status of statuses) {
|
||||
const { unmount } = render(
|
||||
<VerdictBadge verdict={makeVerdict(status)} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId(`verdict-badge-${status.toLowerCase()}`),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -49,6 +49,29 @@ describe('getNodeStyle', () => {
|
|||
const s = getNodeStyle('Call', 'callgraph', { isRecursive: true });
|
||||
expect(s.fill).toBe('#5a5042');
|
||||
});
|
||||
|
||||
it('returns a double shape for surface entry-point nodes', () => {
|
||||
const s = getNodeStyle('EntryPoint', 'surface');
|
||||
expect(s.shape).toBe('double');
|
||||
expect(s.fill).toBe('#1c5c38');
|
||||
});
|
||||
|
||||
it('returns a terminal shape for surface dangerous-local nodes', () => {
|
||||
const s = getNodeStyle('DangerousLocal', 'surface');
|
||||
expect(s.shape).toBe('terminal');
|
||||
expect(s.fill).toBe('#9d2f25');
|
||||
});
|
||||
|
||||
it('returns a warning fill for surface data-store nodes', () => {
|
||||
const s = getNodeStyle('DataStore', 'surface');
|
||||
expect(s.fill).toBe('#8c6310');
|
||||
expect(s.shape).toBe('rect');
|
||||
});
|
||||
|
||||
it('returns an accent fill for surface external-service nodes', () => {
|
||||
const s = getNodeStyle('ExternalService', 'surface');
|
||||
expect(s.fill).toBe('#0b3d2a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEdgeStyle', () => {
|
||||
|
|
@ -90,4 +113,26 @@ describe('getEdgeStyle', () => {
|
|||
const s = getEdgeStyle('Call', 'callgraph');
|
||||
expect(s.dash).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a dashed style for surface auth_required_on edges', () => {
|
||||
const s = getEdgeStyle('auth_required_on', 'surface');
|
||||
expect(s.dash).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it('returns a solid danger color for surface reaches edges', () => {
|
||||
const s = getEdgeStyle('reaches', 'surface');
|
||||
expect(s.color).toBe('#9d2f25');
|
||||
expect(s.dash).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a dashed success style for surface triggers edges', () => {
|
||||
const s = getEdgeStyle('triggers', 'surface');
|
||||
expect(s.dash).toEqual([4, 3]);
|
||||
});
|
||||
|
||||
it('returns a fallback style for unknown surface edge kinds', () => {
|
||||
const s = getEdgeStyle('mystery', 'surface');
|
||||
expect(s.color).toContain('rgba');
|
||||
expect(s.dash).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
110
frontend/src/test/graph/surfaceAdapter.test.ts
Normal file
110
frontend/src/test/graph/surfaceAdapter.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { adaptSurfaceMap, SURFACE_NODE_KIND } from '@/graph/adapters/surface';
|
||||
import type { SurfaceMap } from '@/api/types';
|
||||
|
||||
const SAMPLE: SurfaceMap = {
|
||||
nodes: [
|
||||
{
|
||||
node: 'entry_point',
|
||||
location: { file: 'app.py', line: 10, col: 0 },
|
||||
framework: 'flask',
|
||||
method: 'POST',
|
||||
route: '/api/run',
|
||||
handler_name: 'run',
|
||||
handler_location: { file: 'app.py', line: 12, col: 2 },
|
||||
auth_required: false,
|
||||
},
|
||||
{
|
||||
node: 'data_store',
|
||||
location: { file: 'db.py', line: 40, col: 0 },
|
||||
kind: 'sql',
|
||||
label: 'orders',
|
||||
},
|
||||
{
|
||||
node: 'external_service',
|
||||
location: { file: 'client.py', line: 5, col: 0 },
|
||||
kind: 'http_api',
|
||||
label: 'github.com',
|
||||
},
|
||||
{
|
||||
node: 'dangerous_local',
|
||||
location: { file: 'app.py', line: 24, col: 4 },
|
||||
function_name: 'run',
|
||||
cap_bits: 0x400,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ from: 0, to: 3, kind: 'calls' },
|
||||
{ from: 3, to: 1, kind: 'writes_to' },
|
||||
{ from: 0, to: 2, kind: 'talks_to' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('adaptSurfaceMap', () => {
|
||||
it('produces a surface-kind GraphModel', () => {
|
||||
const model = adaptSurfaceMap(SAMPLE);
|
||||
expect(model.kind).toBe('surface');
|
||||
expect(model.nodes).toHaveLength(4);
|
||||
expect(model.edges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('keys nodes by index so SurfaceEdge.from/to map directly', () => {
|
||||
const model = adaptSurfaceMap(SAMPLE);
|
||||
expect(model.nodes.map((n) => n.key)).toEqual(['0', '1', '2', '3']);
|
||||
expect(model.edges[0]?.source).toBe('0');
|
||||
expect(model.edges[0]?.target).toBe('3');
|
||||
});
|
||||
|
||||
it('maps each SurfaceNode kind to a distinct style discriminator', () => {
|
||||
const model = adaptSurfaceMap(SAMPLE);
|
||||
expect(model.nodes[0]?.kind).toBe(SURFACE_NODE_KIND.entry_point);
|
||||
expect(model.nodes[1]?.kind).toBe(SURFACE_NODE_KIND.data_store);
|
||||
expect(model.nodes[2]?.kind).toBe(SURFACE_NODE_KIND.external_service);
|
||||
expect(model.nodes[3]?.kind).toBe(SURFACE_NODE_KIND.dangerous_local);
|
||||
});
|
||||
|
||||
it('builds entry-point labels from method and route', () => {
|
||||
const model = adaptSurfaceMap(SAMPLE);
|
||||
expect(model.nodes[0]?.label).toBe('POST /api/run');
|
||||
expect(model.nodes[0]?.detail).toBe('flask · run');
|
||||
});
|
||||
|
||||
it('renders dangerous_local cap_bits as hex in detail', () => {
|
||||
const model = adaptSurfaceMap(SAMPLE);
|
||||
expect(model.nodes[3]?.detail).toBe('cap=0x400');
|
||||
});
|
||||
|
||||
it('uses handler_location for entry_point line, location for others', () => {
|
||||
const model = adaptSurfaceMap(SAMPLE);
|
||||
expect(model.nodes[0]?.line).toBe(12);
|
||||
expect(model.nodes[1]?.line).toBe(40);
|
||||
});
|
||||
|
||||
it('emits an auth badge only for entry_points marked auth_required', () => {
|
||||
const protectedEntry = adaptSurfaceMap({
|
||||
nodes: [
|
||||
{
|
||||
...SAMPLE.nodes[0],
|
||||
node: 'entry_point',
|
||||
auth_required: true,
|
||||
} as SurfaceMap['nodes'][0],
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
expect(protectedEntry.nodes[0]?.badges).toEqual(['auth']);
|
||||
const openEntry = adaptSurfaceMap(SAMPLE);
|
||||
expect(openEntry.nodes[0]?.badges).toBeUndefined();
|
||||
});
|
||||
|
||||
it('produces unique edge keys even for parallel edges of the same kind', () => {
|
||||
const parallel: SurfaceMap = {
|
||||
nodes: SAMPLE.nodes,
|
||||
edges: [
|
||||
{ from: 0, to: 1, kind: 'calls' },
|
||||
{ from: 0, to: 1, kind: 'calls' },
|
||||
],
|
||||
};
|
||||
const model = adaptSurfaceMap(parallel);
|
||||
expect(model.edges[0]?.key).not.toBe(model.edges[1]?.key);
|
||||
});
|
||||
});
|
||||
83
frontend/src/test/modals/NewScanModal.test.tsx
Normal file
83
frontend/src/test/modals/NewScanModal.test.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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',
|
||||
verify_backend: 'auto',
|
||||
harden_profile: 'standard',
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it('allows selecting the unsafe process verification backend', async () => {
|
||||
render(<NewScanModal open={true} onClose={vi.fn()} />);
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
fireEvent.change(selects[2], { target: { value: 'process' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start scan' }));
|
||||
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce());
|
||||
const payload = mockMutateAsync.mock.calls[0][0];
|
||||
expect(payload).toMatchObject({
|
||||
verify_backend: 'process',
|
||||
harden_profile: 'standard',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/queryclient.ts","./src/api/types.ts","./src/api/mutations/baseline.ts","./src/api/mutations/config.ts","./src/api/mutations/rules.ts","./src/api/mutations/scans.ts","./src/api/mutations/triage.ts","./src/api/queries/config.ts","./src/api/queries/debug.ts","./src/api/queries/explorer.ts","./src/api/queries/findings.ts","./src/api/queries/health.ts","./src/api/queries/overview.ts","./src/api/queries/rules.ts","./src/api/queries/scans.ts","./src/api/queries/triage.ts","./src/components/copymarkdownbutton.tsx","./src/components/charts/horizontalbarchart.tsx","./src/components/charts/linechart.tsx","./src/components/data-display/codeviewer.tsx","./src/components/data-display/filetree.tsx","./src/components/explorer/analysisworkspace.tsx","./src/components/icons/icons.tsx","./src/components/layout/applayout.tsx","./src/components/layout/headerbar.tsx","./src/components/layout/sidebar.tsx","./src/components/overview/overviewwidgets.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/errorstate.tsx","./src/components/ui/loadingstate.tsx","./src/components/ui/modal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/shortcutshelp.tsx","./src/components/ui/statcard.tsx","./src/components/ui/toaster.tsx","./src/contexts/ssecontext.tsx","./src/contexts/themecontext.tsx","./src/contexts/toastcontext.tsx","./src/graph/styles.ts","./src/graph/types.ts","./src/graph/adapters/callgraph.ts","./src/graph/adapters/cfg.ts","./src/graph/components/callgraphcanvas.tsx","./src/graph/components/cfggraphcanvas.tsx","./src/graph/components/graphtoolbar.tsx","./src/graph/hooks/useelklayout.ts","./src/graph/layout/elk.ts","./src/graph/layout/text.ts","./src/graph/reduction/cfgcompaction.ts","./src/graph/reduction/neighborhood.ts","./src/graph/rendering/sigma/sigmagraph.tsx","./src/graph/rendering/sigma/buildgraph.ts","./src/graph/rendering/sigma/edgeoverlay.ts","./src/hooks/usechordnavigation.ts","./src/hooks/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usepagetitle.ts","./src/hooks/usepersistedstate.ts","./src/modals/codeviewermodal.tsx","./src/modals/newscanmodal.tsx","./src/pages/configpage.tsx","./src/pages/explorerpage.tsx","./src/pages/findingdetailpage.tsx","./src/pages/findingspage.tsx","./src/pages/overviewpage.tsx","./src/pages/rulespage.tsx","./src/pages/scancomparepage.tsx","./src/pages/scandetailpage.tsx","./src/pages/scanspage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/authanalysispage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/pointerviewerpage.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.tsx","./src/pages/debug/typefactspage.tsx","./src/test/setup.ts","./src/test/api/client.test.ts","./src/test/components/pagination.test.tsx","./src/test/components/statcard.test.tsx","./src/test/components/statecomponents.test.tsx","./src/test/graph/cfgadapter.test.ts","./src/test/graph/compactgraph.test.ts","./src/test/graph/nodestyles.test.ts","./src/test/hooks/usedebounce.test.ts","./src/test/utils/findingmarkdown.test.ts","./src/test/utils/formatdate.test.ts","./src/test/utils/syntaxhighlight.test.ts","./src/test/utils/truncpath.test.ts","./src/utils/findingmarkdown.ts","./src/utils/formatdate.ts","./src/utils/parsenote.ts","./src/utils/syntaxhighlight.ts","./src/utils/truncpath.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/queryclient.ts","./src/api/types.ts","./src/api/mutations/baseline.ts","./src/api/mutations/config.ts","./src/api/mutations/rules.ts","./src/api/mutations/scans.ts","./src/api/mutations/triage.ts","./src/api/queries/config.ts","./src/api/queries/debug.ts","./src/api/queries/explorer.ts","./src/api/queries/findings.ts","./src/api/queries/health.ts","./src/api/queries/overview.ts","./src/api/queries/rules.ts","./src/api/queries/scans.ts","./src/api/queries/surface.ts","./src/api/queries/targets.ts","./src/api/queries/triage.ts","./src/components/copymarkdownbutton.tsx","./src/components/verdictbadge.tsx","./src/components/charts/horizontalbarchart.tsx","./src/components/charts/linechart.tsx","./src/components/data-display/codeviewer.tsx","./src/components/data-display/filetree.tsx","./src/components/explorer/analysisworkspace.tsx","./src/components/icons/icons.tsx","./src/components/layout/applayout.tsx","./src/components/layout/headerbar.tsx","./src/components/layout/sidebar.tsx","./src/components/overview/overviewwidgets.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/errorstate.tsx","./src/components/ui/loadingstate.tsx","./src/components/ui/modal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/shortcutshelp.tsx","./src/components/ui/statcard.tsx","./src/components/ui/toaster.tsx","./src/contexts/ssecontext.tsx","./src/contexts/themecontext.tsx","./src/contexts/toastcontext.tsx","./src/graph/styles.ts","./src/graph/types.ts","./src/graph/adapters/callgraph.ts","./src/graph/adapters/cfg.ts","./src/graph/adapters/surface.ts","./src/graph/components/callgraphcanvas.tsx","./src/graph/components/cfggraphcanvas.tsx","./src/graph/components/graphtoolbar.tsx","./src/graph/components/surfacegraphcanvas.tsx","./src/graph/hooks/useelklayout.ts","./src/graph/layout/elk.ts","./src/graph/layout/text.ts","./src/graph/reduction/cfgcompaction.ts","./src/graph/reduction/neighborhood.ts","./src/graph/rendering/sigma/sigmagraph.tsx","./src/graph/rendering/sigma/buildgraph.ts","./src/graph/rendering/sigma/edgeoverlay.ts","./src/hooks/usechordnavigation.ts","./src/hooks/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usepagetitle.ts","./src/hooks/usepersistedstate.ts","./src/modals/codeviewermodal.tsx","./src/modals/newscanmodal.tsx","./src/pages/configpage.tsx","./src/pages/explorerpage.tsx","./src/pages/findingdetailpage.tsx","./src/pages/findingspage.tsx","./src/pages/overviewpage.tsx","./src/pages/rulespage.tsx","./src/pages/scancomparepage.tsx","./src/pages/scandetailpage.tsx","./src/pages/scanspage.tsx","./src/pages/surfacepage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/authanalysispage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/pointerviewerpage.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.tsx","./src/pages/debug/typefactspage.tsx","./src/test/setup.ts","./src/test/api/client.test.ts","./src/test/components/pagination.test.tsx","./src/test/components/statcard.test.tsx","./src/test/components/dynamicverdictsection.test.tsx","./src/test/components/statecomponents.test.tsx","./src/test/components/verdictbadge.test.tsx","./src/test/graph/cfgadapter.test.ts","./src/test/graph/compactgraph.test.ts","./src/test/graph/nodestyles.test.ts","./src/test/graph/surfaceadapter.test.ts","./src/test/hooks/usedebounce.test.ts","./src/test/modals/newscanmodal.test.tsx","./src/test/utils/findingmarkdown.test.ts","./src/test/utils/formatdate.test.ts","./src/test/utils/syntaxhighlight.test.ts","./src/test/utils/truncpath.test.ts","./src/utils/findingmarkdown.ts","./src/utils/formatdate.ts","./src/utils/parsenote.ts","./src/utils/syntaxhighlight.ts","./src/utils/truncpath.ts"],"version":"6.0.3"}
|
||||
0
fuzz-discovered/.gitkeep
Normal file
0
fuzz-discovered/.gitkeep
Normal file
2366
fuzz/dynamic_corpus/Cargo.lock
generated
Normal file
2366
fuzz/dynamic_corpus/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
14
fuzz/dynamic_corpus/Cargo.toml
Normal file
14
fuzz/dynamic_corpus/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "nyx-dynamic-corpus"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "Mutation-based dynamic corpus fuzzer for Nyx payload discovery"
|
||||
|
||||
[dependencies]
|
||||
nyx-scanner = { path = "../..", features = ["dynamic"] }
|
||||
serde_json = "1"
|
||||
|
||||
[[bin]]
|
||||
name = "nyx-dynamic-corpus"
|
||||
path = "src/main.rs"
|
||||
337
fuzz/dynamic_corpus/src/main.rs
Normal file
337
fuzz/dynamic_corpus/src/main.rs
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
//! Dynamic corpus mutation fuzzer.
|
||||
//!
|
||||
//! Seeds from [`nyx_scanner::dynamic::corpus::payloads_for`], mutates bytes,
|
||||
//! runs against an instrumented fixture harness, and writes candidates to
|
||||
//! `fuzz-discovered/{spec_hash}/` when `sink_hit && oracle_fired`.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```text
|
||||
//! # Run against the SSRF corpus with an OOB listener
|
||||
//! cargo run -p nyx-dynamic-corpus -- \
|
||||
//! --cap ssrf \
|
||||
//! --spec-hash 0123456789abcdef \
|
||||
//! --output ../../fuzz-discovered \
|
||||
//! --iterations 1000 \
|
||||
//! --harness-cmd "python3 tests/dynamic_fixtures/ssrf_harness.py"
|
||||
//! ```
|
||||
//!
|
||||
//! Discovered candidates land in `{output}/{spec_hash}/` with a JSON
|
||||
//! provenance sidecar (see §16.1 / §16.4 rationale for manual review gate).
|
||||
|
||||
use nyx_scanner::dynamic::corpus::{
|
||||
audit_marker_collisions, materialise_bytes, payloads_for, CuratedPayload, Oracle,
|
||||
PayloadProvenance, CORPUS_VERSION,
|
||||
};
|
||||
use nyx_scanner::dynamic::rand::SpecRng;
|
||||
use nyx_scanner::labels::Cap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: {} <command>", args[0]);
|
||||
eprintln!("Commands:");
|
||||
eprintln!(" run --cap <cap> --spec-hash <hash> [--output <dir>] [--iterations <n>]");
|
||||
eprintln!(" audit-markers");
|
||||
eprintln!(" list-caps");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
match args[1].as_str() {
|
||||
"audit-markers" => cmd_audit_markers(),
|
||||
"list-caps" => cmd_list_caps(),
|
||||
"run" => cmd_run(&args[2..]),
|
||||
_ => {
|
||||
eprintln!("Unknown command: {}", args[1]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_audit_markers() {
|
||||
let collisions = audit_marker_collisions();
|
||||
if collisions.is_empty() {
|
||||
println!("OK: no marker collisions detected (corpus_version={})", CORPUS_VERSION);
|
||||
} else {
|
||||
eprintln!("FAIL: {} marker collision(s) detected:", collisions.len());
|
||||
for (cap, label, other_cap) in &collisions {
|
||||
eprintln!(" {cap}/{label} marker appears in {other_cap} payload bytes");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_list_caps() {
|
||||
let supported = [
|
||||
("sql_query", Cap::SQL_QUERY),
|
||||
("code_exec", Cap::CODE_EXEC),
|
||||
("file_io", Cap::FILE_IO),
|
||||
("ssrf", Cap::SSRF),
|
||||
("html_escape", Cap::HTML_ESCAPE),
|
||||
];
|
||||
println!("Supported caps (corpus_version={}):", CORPUS_VERSION);
|
||||
for (name, cap) in &supported {
|
||||
let payloads = payloads_for(*cap);
|
||||
println!(" {name}: {} payload(s)", payloads.len());
|
||||
for p in payloads {
|
||||
println!(
|
||||
" - {} [{}] oob_nonce_slot={}",
|
||||
p.label,
|
||||
if p.is_benign { "benign" } else { "vuln" },
|
||||
p.oob_nonce_slot
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_run(args: &[String]) {
|
||||
let cap_name = get_arg(args, "--cap").unwrap_or_else(|| {
|
||||
eprintln!("--cap required"); std::process::exit(1);
|
||||
});
|
||||
let spec_hash = get_arg(args, "--spec-hash").unwrap_or_else(|| {
|
||||
eprintln!("--spec-hash required"); std::process::exit(1);
|
||||
});
|
||||
let output_dir = get_arg(args, "--output").unwrap_or_else(|| "fuzz-discovered".to_owned());
|
||||
let iterations: u64 = get_arg(args, "--iterations")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1000);
|
||||
let harness_cmd = get_arg(args, "--harness-cmd");
|
||||
|
||||
let cap = parse_cap(&cap_name).unwrap_or_else(|| {
|
||||
eprintln!("Unknown cap: {cap_name}. Use list-caps to see supported caps.");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let payloads = payloads_for(cap);
|
||||
if payloads.is_empty() {
|
||||
eprintln!("No payloads for cap {cap_name}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let out_path = PathBuf::from(&output_dir).join(&spec_hash);
|
||||
std::fs::create_dir_all(&out_path).unwrap_or_else(|e| {
|
||||
eprintln!("Cannot create output dir {}: {e}", out_path.display());
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
println!(
|
||||
"Dynamic corpus fuzzer: cap={cap_name} spec_hash={spec_hash} \
|
||||
iterations={iterations} output={}",
|
||||
out_path.display()
|
||||
);
|
||||
|
||||
let mut discovered = 0u64;
|
||||
let mut seen: HashSet<Vec<u8>> = HashSet::new();
|
||||
|
||||
// Seed the fuzzer from the corpus payloads.
|
||||
let seed_bytes: Vec<Vec<u8>> = payloads
|
||||
.iter()
|
||||
.filter(|p| !p.is_benign && !p.oob_nonce_slot)
|
||||
.map(|p| p.bytes.to_vec())
|
||||
.collect();
|
||||
|
||||
if seed_bytes.is_empty() {
|
||||
println!("No static seed payloads for {cap_name} (all are OOB or benign). Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut corpus: Vec<Vec<u8>> = seed_bytes.clone();
|
||||
// Deterministic RNG keyed on the spec hash so two runs against the
|
||||
// same fixture produce identical candidate streams. The Phase 27
|
||||
// events.jsonl replay invariant + Phase 28 repro bundle hermeticity
|
||||
// contract both require the verifier (and any fuzzer feeding it) to
|
||||
// be reproducible from inputs alone — no host entropy mixed in.
|
||||
let mut rng = SpecRng::seeded(&spec_hash);
|
||||
|
||||
for iter in 0..iterations {
|
||||
let seed = &corpus[rng.gen_range(corpus.len())];
|
||||
let candidate = mutate_bytes(seed, &mut rng);
|
||||
|
||||
if seen.contains(&candidate) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(candidate.clone());
|
||||
|
||||
let interesting = if let Some(ref cmd) = harness_cmd {
|
||||
run_candidate_against_harness(&candidate, cmd, payloads)
|
||||
} else {
|
||||
// Headless mode: check heuristically whether the candidate is
|
||||
// structurally plausible for the cap (bypass the subprocess cost).
|
||||
is_structurally_interesting(&candidate, cap)
|
||||
};
|
||||
|
||||
if interesting {
|
||||
discovered += 1;
|
||||
let filename = format!("candidate-{:016x}", rng.next_u64());
|
||||
let candidate_path = out_path.join(&filename);
|
||||
std::fs::write(&candidate_path, &candidate).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to write candidate: {e}");
|
||||
});
|
||||
// Write provenance sidecar.
|
||||
let sidecar = serde_json::json!({
|
||||
"source": "InternalFuzzer",
|
||||
"references": [format!("fuzzer-run-{}", iter)],
|
||||
"since_corpus_version": CORPUS_VERSION,
|
||||
"spec_hash": spec_hash,
|
||||
"cap": cap_name,
|
||||
"bytes_hex": hex_encode(&candidate),
|
||||
});
|
||||
let sidecar_path = out_path.join(format!("{filename}.json"));
|
||||
let _ = std::fs::write(sidecar_path, sidecar.to_string());
|
||||
println!(" [+] iter={iter} candidate={filename}");
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"Done: {iterations} iterations, {discovered} candidates written to {}",
|
||||
out_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn get_arg(args: &[String], name: &str) -> Option<String> {
|
||||
let pos = args.iter().position(|a| a == name)?;
|
||||
args.get(pos + 1).cloned()
|
||||
}
|
||||
|
||||
fn parse_cap(name: &str) -> Option<Cap> {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"sql_query" | "sqli" | "sql" => Some(Cap::SQL_QUERY),
|
||||
"code_exec" | "cmdi" | "rce" => Some(Cap::CODE_EXEC),
|
||||
"file_io" | "path_traversal" | "lfi" => Some(Cap::FILE_IO),
|
||||
"ssrf" => Some(Cap::SSRF),
|
||||
"html_escape" | "xss" => Some(Cap::HTML_ESCAPE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mutate_bytes(input: &[u8], rng: &mut SpecRng) -> Vec<u8> {
|
||||
let mut out = input.to_vec();
|
||||
if out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
match rng.next_u64() % 5 {
|
||||
0 => {
|
||||
// Flip a random byte.
|
||||
let idx = rng.gen_range(out.len());
|
||||
out[idx] ^= (rng.next_u64() as u8) | 1;
|
||||
}
|
||||
1 => {
|
||||
// Insert a byte.
|
||||
let idx = rng.gen_range(out.len() + 1);
|
||||
out.insert(idx, rng.next_u64() as u8);
|
||||
}
|
||||
2 => {
|
||||
// Delete a byte.
|
||||
if out.len() > 1 {
|
||||
let idx = rng.gen_range(out.len());
|
||||
out.remove(idx);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// Append known-interesting bytes.
|
||||
let suffixes: &[&[u8]] = &[
|
||||
b"'", b"\"", b";", b"--", b" OR 1=1", b"<script>", b"../",
|
||||
b"\x00", b"{{", b"|", b"`",
|
||||
];
|
||||
let s = suffixes[rng.gen_range(suffixes.len())];
|
||||
out.extend_from_slice(s);
|
||||
}
|
||||
_ => {
|
||||
// Replace a slice with an interesting pattern.
|
||||
let interesting: &[&[u8]] = &[b"'", b"\"", b"<", b">", b"%00", b"../", b"//"];
|
||||
if !out.is_empty() {
|
||||
let idx = rng.gen_range(out.len());
|
||||
let pat = interesting[rng.gen_range(interesting.len())];
|
||||
let end = (idx + pat.len()).min(out.len());
|
||||
out[idx..end].copy_from_slice(&pat[..end - idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Heuristic: does the candidate look structurally plausible for the cap?
|
||||
/// Used in headless (no-harness) mode.
|
||||
fn is_structurally_interesting(candidate: &[u8], cap: Cap) -> bool {
|
||||
if cap.contains(Cap::SQL_QUERY) {
|
||||
let s = String::from_utf8_lossy(candidate);
|
||||
s.contains('\'') || s.contains("--") || s.to_ascii_uppercase().contains("UNION")
|
||||
} else if cap.contains(Cap::CODE_EXEC) {
|
||||
candidate.contains(&b';') || candidate.contains(&b'|') || candidate.contains(&b'`')
|
||||
} else if cap.contains(Cap::FILE_IO) {
|
||||
let s = String::from_utf8_lossy(candidate);
|
||||
s.contains("../") || s.contains("/etc/")
|
||||
} else if cap.contains(Cap::HTML_ESCAPE) {
|
||||
let s = String::from_utf8_lossy(candidate);
|
||||
s.contains('<') || s.contains('>')
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a candidate against an external harness subprocess.
|
||||
///
|
||||
/// Passes the candidate via `NYX_PAYLOAD_B64` env var and checks for
|
||||
/// `__NYX_SINK_HIT__` sentinel in output.
|
||||
fn run_candidate_against_harness(
|
||||
candidate: &[u8],
|
||||
harness_cmd: &str,
|
||||
payloads: &[CuratedPayload],
|
||||
) -> bool {
|
||||
let b64 = base64_encode(candidate);
|
||||
let oracle_marker = payloads
|
||||
.iter()
|
||||
.filter(|p| !p.is_benign && !p.oob_nonce_slot)
|
||||
.find_map(|p| {
|
||||
if let Oracle::OutputContains(m) = &p.oracle {
|
||||
Some(*m)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let parts: Vec<&str> = harness_cmd.split_whitespace().collect();
|
||||
let (cmd, cmd_args) = match parts.split_first() {
|
||||
Some(s) => s,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let output = std::process::Command::new(cmd)
|
||||
.args(cmd_args)
|
||||
.env("NYX_PAYLOAD_B64", &b64)
|
||||
.output();
|
||||
|
||||
let Ok(out) = output else { return false };
|
||||
|
||||
let combined: Vec<u8> = out.stdout.iter().chain(out.stderr.iter()).copied().collect();
|
||||
let sink_hit = combined.windows(16).any(|w| w == b"__NYX_SINK_HIT__");
|
||||
let oracle = oracle_marker
|
||||
.map(|m| combined.windows(m.len()).any(|w| w == m.as_bytes()))
|
||||
.unwrap_or(false);
|
||||
|
||||
sink_hit && oracle
|
||||
}
|
||||
|
||||
fn hex_encode(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
|
||||
out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
|
||||
if chunk.len() > 1 { out.push(ALPHABET[((n >> 6) & 63) as usize] as char); } else { out.push('='); }
|
||||
if chunk.len() > 2 { out.push(ALPHABET[(n & 63) as usize] as char); } else { out.push('='); }
|
||||
}
|
||||
out
|
||||
}
|
||||
106
scripts/check_corpus_sync.py
Normal file
106
scripts/check_corpus_sync.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/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 scripts/corpus_dashboard.py reads the same CORPUS_VERSION and
|
||||
# payload identities as the canonical Rust registry.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent
|
||||
os.chdir(REPO_ROOT)
|
||||
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
import corpus_dashboard # noqa: E402
|
||||
|
||||
CORPUS_RS = REPO_ROOT / "src" / "dynamic" / "corpus.rs"
|
||||
CORPUS_DIR = REPO_ROOT / "src" / "dynamic" / "corpus"
|
||||
|
||||
|
||||
def parse_corpus_rs_version(path: Path) -> int | None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
version_match = re.search(r"pub const CORPUS_VERSION:\s*u32\s*=\s*(\d+);", text)
|
||||
return int(version_match.group(1)) if version_match else None
|
||||
|
||||
|
||||
def payload_identities(payloads: list[corpus_dashboard.PayloadEntry]) -> set[tuple[str, str, str]]:
|
||||
return {(p.cap, p.lang, p.label) for p in payloads}
|
||||
|
||||
|
||||
def count_raw_payload_blocks(path: Path = CORPUS_DIR) -> int:
|
||||
count = 0
|
||||
for source in path.rglob("*.rs"):
|
||||
if source.name in {"audit.rs", "mod.rs", "registry.rs"}:
|
||||
continue
|
||||
text = source.read_text(encoding="utf-8")
|
||||
count += len(re.findall(r"\bCuratedPayload\s*\{", text))
|
||||
return count
|
||||
|
||||
|
||||
def fmt_identity(identity: tuple[str, str, str]) -> str:
|
||||
cap, lang, label = identity
|
||||
return f"{cap}/{lang}/{label}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rs_version = parse_corpus_rs_version(CORPUS_RS)
|
||||
dashboard_version = corpus_dashboard.CORPUS_VERSION
|
||||
registry_payloads = corpus_dashboard.load_payloads()
|
||||
raw_payload_count = count_raw_payload_blocks()
|
||||
|
||||
ok = True
|
||||
|
||||
if rs_version is None:
|
||||
print("ERROR: CORPUS_VERSION not found in corpus.rs")
|
||||
ok = False
|
||||
elif rs_version == dashboard_version:
|
||||
print(f"CORPUS_VERSION: {rs_version} [match]")
|
||||
else:
|
||||
print(
|
||||
"CORPUS_VERSION mismatch: "
|
||||
f"corpus.rs={rs_version} corpus_dashboard.py={dashboard_version}"
|
||||
)
|
||||
ok = False
|
||||
|
||||
registry_ids = payload_identities(registry_payloads)
|
||||
dashboard_ids = payload_identities(corpus_dashboard.PAYLOADS)
|
||||
only_in_registry = registry_ids - dashboard_ids
|
||||
only_in_dashboard = dashboard_ids - registry_ids
|
||||
shared = registry_ids & dashboard_ids
|
||||
|
||||
print(f"Payload identities in both: {len(shared)}")
|
||||
if only_in_registry:
|
||||
print(f"Payload identities only in Rust registry: {len(only_in_registry)}")
|
||||
for identity in sorted(only_in_registry):
|
||||
print(f" + {fmt_identity(identity)}")
|
||||
ok = False
|
||||
if only_in_dashboard:
|
||||
print(f"Payload identities only in dashboard: {len(only_in_dashboard)}")
|
||||
for identity in sorted(only_in_dashboard):
|
||||
print(f" - {fmt_identity(identity)}")
|
||||
ok = False
|
||||
|
||||
if len(corpus_dashboard.PAYLOADS) == raw_payload_count:
|
||||
print(f"CuratedPayload blocks covered: {raw_payload_count} [match]")
|
||||
else:
|
||||
print(
|
||||
"CuratedPayload block count mismatch: "
|
||||
f"source_tree={raw_payload_count} dashboard={len(corpus_dashboard.PAYLOADS)}"
|
||||
)
|
||||
ok = False
|
||||
|
||||
if ok:
|
||||
print("Corpus sync: OK")
|
||||
return 0
|
||||
|
||||
print("Corpus sync: FAIL - update corpus_dashboard.py to match the Rust registry")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
104
scripts/check_no_unseeded_rand.sh
Executable file
104
scripts/check_no_unseeded_rand.sh
Executable file
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env bash
|
||||
# Phase 30 — Track C: determinism audit gate.
|
||||
#
|
||||
# Greps `src/dynamic/` for non-deterministic RNG APIs. Anything inside
|
||||
# the dynamic verifier must route through `crate::dynamic::rand::SpecRng`
|
||||
# so identical inputs produce identical sandbox runs; the Phase 27
|
||||
# `events.jsonl` replay invariant and the Phase 28 repro bundle
|
||||
# hermeticity contract both depend on it.
|
||||
#
|
||||
# Exits 0 on a clean tree, 1 when any banned API surfaces. CI wires
|
||||
# this into the dynamic workflow so a regression fails the build before
|
||||
# it ships.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DYN_DIR="$ROOT/src/dynamic"
|
||||
FUZZ_DIR="$ROOT/fuzz/dynamic_corpus/src"
|
||||
|
||||
if [[ ! -d "$DYN_DIR" ]]; then
|
||||
echo "audit: src/dynamic/ missing at $DYN_DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The dynamic-corpus mutation fuzzer is also audited: it routes every
|
||||
# randomness draw through `SpecRng::seeded(&spec.spec_hash)` so two
|
||||
# runs against the same fixture produce identical candidate streams,
|
||||
# matching the determinism contract of the verifier it feeds.
|
||||
if [[ ! -d "$FUZZ_DIR" ]]; then
|
||||
# Soft warn — the fuzzer is optional during early bootstrap.
|
||||
echo "audit: fuzz/dynamic_corpus/src/ missing at $FUZZ_DIR (skipping)" >&2
|
||||
fi
|
||||
|
||||
# Banned patterns: any real call site of a non-deterministic RNG API.
|
||||
#
|
||||
# Each pattern is a Rust-token shape we expect to never appear inside
|
||||
# src/dynamic/ once Phase 30 lands. The seccomp policy file (which
|
||||
# names the "getrandom" syscall as a string literal) is excluded
|
||||
# because its mention is a syscall name, not a Rust API call — the
|
||||
# string-literal regex below matches the bare token, and the seccomp
|
||||
# files spell it inside quotes that look identical, so we exclude the
|
||||
# seccomp subtree explicitly.
|
||||
PATTERNS=(
|
||||
'rand::thread_rng'
|
||||
'thread_rng\s*\('
|
||||
'rand::random'
|
||||
'OsRng'
|
||||
'from_entropy'
|
||||
'getrandom::getrandom'
|
||||
'Uuid::new_v4'
|
||||
'uuid::Uuid::new_v4'
|
||||
'fastrand'
|
||||
'nanoid'
|
||||
)
|
||||
|
||||
EXCLUDE_PATHS=(
|
||||
"$DYN_DIR/sandbox/seccomp"
|
||||
"$DYN_DIR/rand.rs"
|
||||
)
|
||||
|
||||
# Use `git grep` when inside a git repo (respects .gitignore), fall
|
||||
# back to `grep -r` otherwise. Either way the exclusion list is
|
||||
# applied via a post-filter so the audit catches new files even
|
||||
# before they are tracked.
|
||||
if git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
HITS="$(git -C "$ROOT" grep -nE "$(IFS='|'; echo "${PATTERNS[*]}")" \
|
||||
-- 'src/dynamic/**/*.rs' 'src/dynamic/*.rs' \
|
||||
'fuzz/dynamic_corpus/src/**/*.rs' 'fuzz/dynamic_corpus/src/*.rs' \
|
||||
|| true)"
|
||||
else
|
||||
HITS="$(grep -rnE "$(IFS='|'; echo "${PATTERNS[*]}")" --include='*.rs' \
|
||||
"$DYN_DIR" ${FUZZ_DIR:+"$FUZZ_DIR"} || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$HITS" ]]; then
|
||||
echo "audit: src/dynamic/ is free of unseeded RNG APIs"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FILTERED=""
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
path="${line%%:*}"
|
||||
skip=0
|
||||
for ex in "${EXCLUDE_PATHS[@]}"; do
|
||||
case "$path" in
|
||||
"$ex"*|"${ex#$ROOT/}"*) skip=1; break ;;
|
||||
esac
|
||||
done
|
||||
if [[ $skip -eq 0 ]]; then
|
||||
FILTERED+="$line"$'\n'
|
||||
fi
|
||||
done <<< "$HITS"
|
||||
|
||||
if [[ -z "${FILTERED//[$' \t\n\r']/}" ]]; then
|
||||
echo "audit: src/dynamic/ is free of unseeded RNG APIs"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "audit: banned RNG APIs surfaced inside src/dynamic/" >&2
|
||||
echo "$FILTERED" >&2
|
||||
echo >&2
|
||||
echo "Replace with crate::dynamic::rand::SpecRng::seeded(&spec.spec_hash)." >&2
|
||||
exit 1
|
||||
569
scripts/corpus_dashboard.py
Executable file
569
scripts/corpus_dashboard.py
Executable file
|
|
@ -0,0 +1,569 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Corpus health report for the Rust dynamic payload registry.
|
||||
|
||||
Produces:
|
||||
- Per-cap coverage table (payload count, benign controls, OOB slots)
|
||||
- Per-payload last-confirmed timestamp (from repro artifacts if present)
|
||||
- CVE reference count
|
||||
- Marker collision audit
|
||||
|
||||
Exit code 0 = healthy. Non-zero = collision or missing coverage.
|
||||
|
||||
Usage:
|
||||
python3 scripts/corpus_dashboard.py [--repro-dir REPRO_DIR] [--json]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent
|
||||
CORPUS_RS = REPO_ROOT / "src" / "dynamic" / "corpus.rs"
|
||||
CORPUS_DIR = REPO_ROOT / "src" / "dynamic" / "corpus"
|
||||
REGISTRY_RS = CORPUS_DIR / "registry.rs"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistryEntry:
|
||||
cap: str
|
||||
lang: str
|
||||
module_path: str
|
||||
source_path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class PayloadEntry:
|
||||
cap: str
|
||||
lang: str
|
||||
label: str
|
||||
bytes_repr: str
|
||||
oracle_kind: str
|
||||
oracle_value: Optional[str]
|
||||
is_benign: bool
|
||||
provenance: str
|
||||
since_corpus_version: int
|
||||
deprecated_at_corpus_version: Optional[int]
|
||||
fixture_paths: list[str]
|
||||
oob_nonce_slot: bool
|
||||
source_path: str
|
||||
cve_refs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# Rust source helpers ---------------------------------------------------------
|
||||
|
||||
|
||||
def load_corpus_version(path: Path = CORPUS_RS) -> int:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
match = re.search(r"pub const CORPUS_VERSION:\s*u32\s*=\s*(\d+);", text)
|
||||
if not match:
|
||||
raise ValueError(f"CORPUS_VERSION not found in {path}")
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def parse_registry_entries(path: Path = REGISTRY_RS) -> list[RegistryEntry]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
entries: list[RegistryEntry] = []
|
||||
pattern = re.compile(
|
||||
r"\(\s*Cap::([A-Z0-9_]+)\s*,\s*Lang::([A-Za-z0-9_]+)\s*,"
|
||||
r"\s*([A-Za-z0-9_:]+)::PAYLOADS\s*,?\s*\)",
|
||||
re.DOTALL,
|
||||
)
|
||||
for match in pattern.finditer(text):
|
||||
cap, lang, module_path = match.groups()
|
||||
source_path = CORPUS_DIR / f"{module_path.replace('::', '/')}.rs"
|
||||
entries.append(RegistryEntry(cap, lang, module_path, source_path))
|
||||
if not entries:
|
||||
raise ValueError(f"No registry entries found in {path}")
|
||||
return entries
|
||||
|
||||
|
||||
def _raw_string_bounds(text: str, index: int) -> Optional[tuple[int, int, int]]:
|
||||
if text.startswith("br", index):
|
||||
marker_index = index + 2
|
||||
elif text.startswith("r", index):
|
||||
marker_index = index + 1
|
||||
else:
|
||||
return None
|
||||
|
||||
cursor = marker_index
|
||||
while cursor < len(text) and text[cursor] == "#":
|
||||
cursor += 1
|
||||
if cursor >= len(text) or text[cursor] != '"':
|
||||
return None
|
||||
|
||||
hashes = text[marker_index:cursor]
|
||||
body_start = cursor + 1
|
||||
terminator = '"' + hashes
|
||||
body_end = text.find(terminator, body_start)
|
||||
if body_end < 0:
|
||||
raise ValueError("unterminated Rust raw string literal")
|
||||
return body_start, body_end, body_end + len(terminator)
|
||||
|
||||
|
||||
def _quoted_literal_end(text: str, index: int) -> Optional[int]:
|
||||
raw = _raw_string_bounds(text, index)
|
||||
if raw:
|
||||
return raw[2]
|
||||
|
||||
if text.startswith('b"', index):
|
||||
quote = '"'
|
||||
cursor = index + 2
|
||||
elif text[index:index + 1] == '"':
|
||||
quote = '"'
|
||||
cursor = index + 1
|
||||
elif (
|
||||
text[index:index + 1] == "'"
|
||||
and index + 1 < len(text)
|
||||
and not (text[index + 1].isalpha() or text[index + 1] == "_")
|
||||
):
|
||||
quote = "'"
|
||||
cursor = index + 1
|
||||
else:
|
||||
return None
|
||||
|
||||
while cursor < len(text):
|
||||
char = text[cursor]
|
||||
if char == "\\":
|
||||
cursor += 2
|
||||
continue
|
||||
if char == quote:
|
||||
return cursor + 1
|
||||
cursor += 1
|
||||
raise ValueError("unterminated Rust quoted literal")
|
||||
|
||||
|
||||
def _skip_ignored(text: str, index: int) -> int:
|
||||
if text.startswith("//", index):
|
||||
newline = text.find("\n", index + 2)
|
||||
return len(text) if newline < 0 else newline + 1
|
||||
|
||||
if text.startswith("/*", index):
|
||||
depth = 1
|
||||
cursor = index + 2
|
||||
while cursor < len(text) and depth:
|
||||
if text.startswith("/*", cursor):
|
||||
depth += 1
|
||||
cursor += 2
|
||||
elif text.startswith("*/", cursor):
|
||||
depth -= 1
|
||||
cursor += 2
|
||||
else:
|
||||
cursor += 1
|
||||
if depth:
|
||||
raise ValueError("unterminated Rust block comment")
|
||||
return cursor
|
||||
|
||||
literal_end = _quoted_literal_end(text, index)
|
||||
return literal_end if literal_end is not None else index
|
||||
|
||||
|
||||
def _find_matching(text: str, open_index: int, open_char: str, close_char: str) -> int:
|
||||
depth = 1
|
||||
cursor = open_index + 1
|
||||
while cursor < len(text):
|
||||
skipped = _skip_ignored(text, cursor)
|
||||
if skipped != cursor:
|
||||
cursor = skipped
|
||||
continue
|
||||
|
||||
char = text[cursor]
|
||||
if char == open_char:
|
||||
depth += 1
|
||||
elif char == close_char:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return cursor
|
||||
cursor += 1
|
||||
raise ValueError(f"unterminated {open_char}{close_char} block")
|
||||
|
||||
|
||||
def _payload_blocks(text: str) -> list[str]:
|
||||
blocks: list[str] = []
|
||||
for match in re.finditer(r"\bCuratedPayload\s*\{", text):
|
||||
open_index = match.end() - 1
|
||||
close_index = _find_matching(text, open_index, "{", "}")
|
||||
blocks.append(text[open_index + 1:close_index])
|
||||
return blocks
|
||||
|
||||
|
||||
def _add_field(segment: str, fields: dict[str, str]) -> None:
|
||||
match = re.search(r"(^|\n)\s*([A-Za-z_][A-Za-z0-9_]*)\s*:", segment)
|
||||
if not match:
|
||||
return
|
||||
fields[match.group(2)] = segment[match.end():].strip()
|
||||
|
||||
|
||||
def _split_top_level_fields(block: str) -> dict[str, str]:
|
||||
fields: dict[str, str] = {}
|
||||
start = 0
|
||||
cursor = 0
|
||||
brace_depth = 0
|
||||
bracket_depth = 0
|
||||
paren_depth = 0
|
||||
|
||||
while cursor < len(block):
|
||||
skipped = _skip_ignored(block, cursor)
|
||||
if skipped != cursor:
|
||||
cursor = skipped
|
||||
continue
|
||||
|
||||
char = block[cursor]
|
||||
if char == "{":
|
||||
brace_depth += 1
|
||||
elif char == "}":
|
||||
brace_depth -= 1
|
||||
elif char == "[":
|
||||
bracket_depth += 1
|
||||
elif char == "]":
|
||||
bracket_depth -= 1
|
||||
elif char == "(":
|
||||
paren_depth += 1
|
||||
elif char == ")":
|
||||
paren_depth -= 1
|
||||
elif (
|
||||
char == ","
|
||||
and brace_depth == 0
|
||||
and bracket_depth == 0
|
||||
and paren_depth == 0
|
||||
):
|
||||
_add_field(block[start:cursor], fields)
|
||||
start = cursor + 1
|
||||
cursor += 1
|
||||
|
||||
_add_field(block[start:], fields)
|
||||
return fields
|
||||
|
||||
|
||||
def _parse_rust_string_literal(text: str, index: int) -> Optional[tuple[str, int]]:
|
||||
raw = _raw_string_bounds(text, index)
|
||||
if raw:
|
||||
body_start, body_end, literal_end = raw
|
||||
return text[body_start:body_end], literal_end
|
||||
|
||||
if text.startswith('b"', index):
|
||||
cursor = index + 2
|
||||
elif text[index:index + 1] == '"':
|
||||
cursor = index + 1
|
||||
else:
|
||||
return None
|
||||
|
||||
while cursor < len(text):
|
||||
char = text[cursor]
|
||||
if char == "\\":
|
||||
cursor += 2
|
||||
continue
|
||||
if char == '"':
|
||||
literal = text[index:cursor + 1]
|
||||
value = ast.literal_eval(literal)
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("latin-1"), cursor + 1
|
||||
return str(value), cursor + 1
|
||||
cursor += 1
|
||||
raise ValueError("unterminated Rust string literal")
|
||||
|
||||
|
||||
def _rust_string_literals(expr: str) -> list[str]:
|
||||
strings: list[str] = []
|
||||
cursor = 0
|
||||
while cursor < len(expr):
|
||||
if expr.startswith("//", cursor) or expr.startswith("/*", cursor):
|
||||
cursor = _skip_ignored(expr, cursor)
|
||||
continue
|
||||
|
||||
parsed = _parse_rust_string_literal(expr, cursor)
|
||||
if parsed:
|
||||
value, cursor = parsed
|
||||
strings.append(value)
|
||||
continue
|
||||
|
||||
cursor += 1
|
||||
return strings
|
||||
|
||||
|
||||
def _parse_string_constants(text: str) -> dict[str, str]:
|
||||
constants: dict[str, str] = {}
|
||||
pattern = re.compile(r"(?:pub\s+)?const\s+([A-Z][A-Z0-9_]*):\s*&str\s*=\s*([^;]+);")
|
||||
for match in pattern.finditer(text):
|
||||
strings = _rust_string_literals(match.group(2))
|
||||
if strings:
|
||||
constants[match.group(1)] = strings[0]
|
||||
return constants
|
||||
|
||||
|
||||
def _required(fields: dict[str, str], name: str, source_path: Path) -> str:
|
||||
if name not in fields:
|
||||
rel = source_path.relative_to(REPO_ROOT)
|
||||
raise ValueError(f"missing field {name!r} in payload from {rel}")
|
||||
return fields[name]
|
||||
|
||||
|
||||
def _string_expr(expr: str, constants: dict[str, str]) -> str:
|
||||
expr = expr.strip()
|
||||
if expr in constants:
|
||||
return constants[expr]
|
||||
strings = _rust_string_literals(expr)
|
||||
if strings:
|
||||
return strings[0]
|
||||
return expr
|
||||
|
||||
|
||||
def _bool_expr(expr: str) -> bool:
|
||||
value = expr.strip()
|
||||
if value == "true":
|
||||
return True
|
||||
if value == "false":
|
||||
return False
|
||||
raise ValueError(f"expected Rust bool literal, got {value!r}")
|
||||
|
||||
|
||||
def _int_expr(expr: str) -> int:
|
||||
match = re.search(r"\d+", expr)
|
||||
if not match:
|
||||
raise ValueError(f"expected integer literal, got {expr!r}")
|
||||
return int(match.group(0))
|
||||
|
||||
|
||||
def _optional_int_expr(expr: str) -> Optional[int]:
|
||||
expr = expr.strip()
|
||||
if expr == "None":
|
||||
return None
|
||||
match = re.fullmatch(r"Some\(\s*(\d+)\s*\)", expr)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
raise ValueError(f"expected Rust Option<u32> literal, got {expr!r}")
|
||||
|
||||
|
||||
def _oracle_expr(expr: str, constants: dict[str, str]) -> tuple[str, Optional[str]]:
|
||||
expr = expr.strip()
|
||||
if expr.startswith("Oracle::OutputContains"):
|
||||
open_index = expr.find("(")
|
||||
close_index = _find_matching(expr, open_index, "(", ")")
|
||||
marker = _string_expr(expr[open_index + 1:close_index], constants)
|
||||
return "OutputContains", marker
|
||||
|
||||
if expr.startswith("Oracle::OobCallback"):
|
||||
strings = _rust_string_literals(expr)
|
||||
return "OobCallback", f"host={strings[0]}" if strings else None
|
||||
|
||||
if expr.startswith("Oracle::SinkCrash"):
|
||||
return "SinkCrash", "signals=all"
|
||||
|
||||
if expr.startswith("Oracle::SinkProbe"):
|
||||
predicates = list(dict.fromkeys(re.findall(r"ProbePredicate::([A-Za-z0-9_]+)", expr)))
|
||||
return "SinkProbe", ",".join(predicates) if predicates else None
|
||||
|
||||
return expr.split("{", 1)[0].split("(", 1)[0].strip(), None
|
||||
|
||||
|
||||
def _payload_from_block(
|
||||
entry: RegistryEntry,
|
||||
block: str,
|
||||
constants: dict[str, str],
|
||||
) -> PayloadEntry:
|
||||
fields = _split_top_level_fields(block)
|
||||
source_path = entry.source_path
|
||||
oracle_kind, oracle_value = _oracle_expr(_required(fields, "oracle", source_path), constants)
|
||||
rel_source = str(source_path.relative_to(REPO_ROOT))
|
||||
|
||||
return PayloadEntry(
|
||||
cap=entry.cap,
|
||||
lang=entry.lang,
|
||||
label=_string_expr(_required(fields, "label", source_path), constants),
|
||||
bytes_repr=_string_expr(_required(fields, "bytes", source_path), constants),
|
||||
oracle_kind=oracle_kind,
|
||||
oracle_value=oracle_value,
|
||||
is_benign=_bool_expr(_required(fields, "is_benign", source_path)),
|
||||
provenance=_required(fields, "provenance", source_path)
|
||||
.strip()
|
||||
.removeprefix("PayloadProvenance::"),
|
||||
since_corpus_version=_int_expr(_required(fields, "since_corpus_version", source_path)),
|
||||
deprecated_at_corpus_version=_optional_int_expr(
|
||||
_required(fields, "deprecated_at_corpus_version", source_path)
|
||||
),
|
||||
fixture_paths=_rust_string_literals(_required(fields, "fixture_paths", source_path)),
|
||||
oob_nonce_slot=_bool_expr(_required(fields, "oob_nonce_slot", source_path)),
|
||||
source_path=rel_source,
|
||||
cve_refs=sorted(set(re.findall(r"CVE-\d{4}-\d{4,7}", block))),
|
||||
)
|
||||
|
||||
|
||||
def load_payloads() -> list[PayloadEntry]:
|
||||
payloads: list[PayloadEntry] = []
|
||||
for entry in parse_registry_entries():
|
||||
if not entry.source_path.exists():
|
||||
rel = entry.source_path.relative_to(REPO_ROOT)
|
||||
raise FileNotFoundError(f"registry entry points at missing payload file: {rel}")
|
||||
|
||||
text = entry.source_path.read_text(encoding="utf-8")
|
||||
constants = _parse_string_constants(text)
|
||||
blocks = _payload_blocks(text)
|
||||
if not blocks:
|
||||
rel = entry.source_path.relative_to(REPO_ROOT)
|
||||
raise ValueError(f"no CuratedPayload entries found in {rel}")
|
||||
|
||||
for block in blocks:
|
||||
payloads.append(_payload_from_block(entry, block, constants))
|
||||
|
||||
return payloads
|
||||
|
||||
|
||||
CORPUS_VERSION = load_corpus_version()
|
||||
PAYLOADS: list[PayloadEntry] = load_payloads()
|
||||
ALL_CAPS = list(dict.fromkeys(p.cap for p in PAYLOADS))
|
||||
|
||||
|
||||
# Marker collision audit ------------------------------------------------------
|
||||
|
||||
|
||||
def audit_marker_collisions(payloads: list[PayloadEntry] = PAYLOADS) -> list[tuple[str, str, str]]:
|
||||
collisions = []
|
||||
for payload in payloads:
|
||||
if payload.is_benign or payload.oracle_kind != "OutputContains":
|
||||
continue
|
||||
marker = payload.oracle_value or ""
|
||||
if not marker:
|
||||
continue
|
||||
|
||||
for other in payloads:
|
||||
if other.cap == payload.cap:
|
||||
continue
|
||||
if other.is_benign or other.oob_nonce_slot:
|
||||
continue
|
||||
if marker in other.bytes_repr:
|
||||
collisions.append((payload.cap, payload.label, other.cap))
|
||||
return collisions
|
||||
|
||||
|
||||
# Coverage table --------------------------------------------------------------
|
||||
|
||||
|
||||
def build_coverage_table(payloads: list[PayloadEntry] = PAYLOADS) -> dict:
|
||||
result = {}
|
||||
for cap in ALL_CAPS:
|
||||
cap_payloads = [payload for payload in payloads if payload.cap == cap]
|
||||
result[cap] = {
|
||||
"total": len(cap_payloads),
|
||||
"vuln": sum(1 for p in cap_payloads if not p.is_benign),
|
||||
"benign": sum(1 for p in cap_payloads if p.is_benign),
|
||||
"oob_slots": sum(1 for p in cap_payloads if p.oob_nonce_slot),
|
||||
"has_fixture_paths": all(len(p.fixture_paths) > 0 for p in cap_payloads),
|
||||
"payloads": [p.label for p in cap_payloads],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
# Repro artifact timestamps ---------------------------------------------------
|
||||
|
||||
|
||||
def scan_last_confirmed(repro_dir: Path) -> dict[str, str]:
|
||||
"""Return {payload_label: iso_timestamp} from repro artifact metadata."""
|
||||
timestamps: dict[str, str] = {}
|
||||
if not repro_dir.exists():
|
||||
return timestamps
|
||||
for meta_file in repro_dir.rglob("*.json"):
|
||||
try:
|
||||
data = json.loads(meta_file.read_text())
|
||||
label = data.get("payload_label", "")
|
||||
ts = data.get("confirmed_at", "")
|
||||
if label and ts:
|
||||
if label not in timestamps or ts > timestamps[label]:
|
||||
timestamps[label] = ts
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
return timestamps
|
||||
|
||||
|
||||
# fuzz-discovered count -------------------------------------------------------
|
||||
|
||||
|
||||
def count_discovered(discovered_dir: Path) -> int:
|
||||
if not discovered_dir.exists():
|
||||
return 0
|
||||
return sum(
|
||||
1 for path in discovered_dir.rglob("*")
|
||||
if path.is_file() and not path.name.endswith(".json") and path.name != ".gitkeep"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Nyx corpus health dashboard")
|
||||
parser.add_argument("--repro-dir", default="repro", help="Path to repro artifacts")
|
||||
parser.add_argument(
|
||||
"--discovered-dir",
|
||||
default="fuzz-discovered",
|
||||
help="Path to fuzz-discovered/ directory",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Output JSON instead of text")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.chdir(REPO_ROOT)
|
||||
|
||||
collisions = audit_marker_collisions()
|
||||
coverage = build_coverage_table()
|
||||
timestamps = scan_last_confirmed(Path(args.repro_dir))
|
||||
discovered_count = count_discovered(Path(args.discovered_dir))
|
||||
|
||||
report = {
|
||||
"corpus_version": CORPUS_VERSION,
|
||||
"registry_entries": len(parse_registry_entries()),
|
||||
"total_payloads": len(PAYLOADS),
|
||||
"coverage": coverage,
|
||||
"marker_collisions": collisions,
|
||||
"last_confirmed": timestamps,
|
||||
"cve_reference_count": sum(len(p.cve_refs) for p in PAYLOADS),
|
||||
"fuzz_discovered_pending": discovered_count,
|
||||
"healthy": len(collisions) == 0,
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2))
|
||||
return 0 if report["healthy"] else 1
|
||||
|
||||
print(f"Nyx Corpus Dashboard (corpus_version={CORPUS_VERSION})")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
print("Per-cap coverage:")
|
||||
hdr = f" {'Cap':<22} {'Total':>5} {'Vuln':>5} {'Benign':>6} {'OOB':>4} {'Fixtures':>8}"
|
||||
print(hdr)
|
||||
print(" " + "-" * 56)
|
||||
for cap, info in coverage.items():
|
||||
fixture_ok = "ok" if info["has_fixture_paths"] else "MISSING"
|
||||
print(
|
||||
f" {cap:<22} {info['total']:>5} {info['vuln']:>5} "
|
||||
f"{info['benign']:>6} {info['oob_slots']:>4} {fixture_ok:>8}"
|
||||
)
|
||||
print()
|
||||
|
||||
if timestamps:
|
||||
print("Last confirmed timestamps:")
|
||||
for label, ts in sorted(timestamps.items()):
|
||||
print(f" {label:<35} {ts}")
|
||||
print()
|
||||
|
||||
print(f"Registry entries: {report['registry_entries']}")
|
||||
print(f"CVE references: {report['cve_reference_count']}")
|
||||
print(f"Fuzz-discovered pending promotion: {discovered_count}")
|
||||
print()
|
||||
|
||||
if collisions:
|
||||
print("FAIL: Marker collisions detected (section 16.3):")
|
||||
for cap, label, other_cap in collisions:
|
||||
print(f" {cap}/{label} marker appears in {other_cap} payload bytes")
|
||||
return 1
|
||||
|
||||
print("OK: No marker collisions detected.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
626
scripts/m7_ship_gate.sh
Executable file
626
scripts/m7_ship_gate.sh
Executable file
|
|
@ -0,0 +1,626 @@
|
|||
#!/usr/bin/env bash
|
||||
# m7_ship_gate.sh — milestone-7 ship gates.
|
||||
#
|
||||
# Each gate runs as an isolated function so CI can call a subset:
|
||||
#
|
||||
# scripts/m7_ship_gate.sh # every gate
|
||||
# scripts/m7_ship_gate.sh --gates 3,6 # only gates 3 + 6
|
||||
# scripts/m7_ship_gate.sh --sets owasp # Java OWASP corpus only
|
||||
# scripts/m7_ship_gate.sh --sets jsts # NodeGoat + Juice Shop only
|
||||
# scripts/m7_ship_gate.sh --sets nodegoat # one JS/TS corpus only
|
||||
# scripts/m7_ship_gate.sh --sets polyglot # RailsGoat+DVWA+DVPWA+gosec+RustSec
|
||||
# scripts/m7_ship_gate.sh --sets railsgoat # one polyglot corpus only
|
||||
#
|
||||
# Gate map (kept in sync with .pitboss/play/plan.md track M.7):
|
||||
# Gate 1: Static-only scan is green on `tests/benchmark/corpus`.
|
||||
# Gate 2: `cargo nextest run --no-fail-fast --features dynamic` is green.
|
||||
# Gate 3: With-verify / static-only wall-clock ratio ≤ 1.5× on
|
||||
# `benches/fixtures/`. Phase 22 had relaxed this to ≤ 2×
|
||||
# while only `javac` had a warm daemon; Phase 23 lands the
|
||||
# cross-lang build pools (shared caches for Node/Python/PHP/
|
||||
# Ruby/Go/Rust/C/C++), so the bar is tightened back to ≤ 1.5×.
|
||||
# Gate 4: SARIF schema validation on every dynamic verdict variant.
|
||||
# Gate 5: Layering boundary test green.
|
||||
# Gate 6: Java OWASP Benchmark v1.2 `--verify` acceptance. Wall-clock
|
||||
# ≤ 15 min on CI / ≤ 10 min on the dev reference machine; and,
|
||||
# per OWASP cap backed by a sound runtime oracle, confirmed-rate
|
||||
# ≥ 40%, precision ≥ 0.85, recall ≥ 0.40, plus the per-(cap,lang)
|
||||
# budget in tests/eval_corpus/budget.toml. Added Phase 22 as the
|
||||
# headline acceptance for the warm `javac` daemon; Phase 27 (Track
|
||||
# R.0) added the precision/recall/budget ratchet. The corpus is
|
||||
# *not* checked into the repo; the gate skips with a clear message
|
||||
# when `NYX_OWASP_CORPUS` does not point at a real checkout.
|
||||
# Gate 7: JS/TS real-corpus acceptance (Track R.1 / Phase 28). OWASP
|
||||
# NodeGoat (Express, .js) + OWASP Juice Shop (TypeScript, .ts)
|
||||
# `--verify` against the committed ground truth. Same shape as
|
||||
# Gate 6: wall-clock budget + the per-(cap,lang) budget in
|
||||
# tests/eval_corpus/budget.toml hard-enforced; per-cap
|
||||
# confirmed-rate / precision / recall published report-only
|
||||
# (NYX_JSTS_FLOOR_CAPS empty by default). Each corpus row
|
||||
# self-skips unless its NYX_NODEGOAT_CORPUS / NYX_JUICESHOP_CORPUS
|
||||
# points at a real checkout.
|
||||
# Gate 8: Polyglot real-corpus acceptance (Track R.2 / Phase 29). OWASP
|
||||
# RailsGoat (Rails, .rb), DVWA (PHP), DVPWA (aiohttp, .py), gosec
|
||||
# (Go) and the RustSec advisory-db (Rust negative control), one
|
||||
# row per corpus. Same shape as Gate 7: wall-clock budget + the
|
||||
# per-(cap,lang) budget hard-enforced; per-cap confirmed/precision/
|
||||
# recall report-only (NYX_POLYGLOT_FLOOR_CAPS empty by default).
|
||||
# Each row self-skips unless its NYX_<NAME>_CORPUS points at a real
|
||||
# checkout.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Demote the per-cell Unsupported-rate budget (Gates 6/7/8 -> report.py) to
|
||||
# report-only in CI. Dynamic confirmation is environment-constrained on the
|
||||
# unprivileged CI runners (no oracle infrastructure for several caps), so the
|
||||
# Unsupported budget — calibrated on a dev box where confirmation runs fully —
|
||||
# would fail vacuously there; the precision (false-Confirmed) and confirmed-rate
|
||||
# ratchets stay HARD. Local runs leave it unset, so coverage stays gated. Set
|
||||
# here rather than in eval.yml so the standalone tabulate regression-test step
|
||||
# (which asserts the hard behaviour) never inherits it.
|
||||
if [[ -n "${CI:-}" ]]; then
|
||||
export NYX_EVAL_SOFT_UNSUPPORTED=1
|
||||
fi
|
||||
|
||||
GATES="1,2,3,4,5,6,7,8"
|
||||
SETS=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gates)
|
||||
GATES="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sets)
|
||||
SETS="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
sed -n '2,/^$/p' "${BASH_SOURCE[0]}"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown flag: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# `--sets` lets CI run a single real-corpus gate. `owasp` -> Gate 6;
|
||||
# `jsts` (both JS/TS corpora) / `nodegoat` / `juiceshop` -> Gate 7, with the
|
||||
# corpus name passed through so Gate 7 runs only the requested row.
|
||||
case "${SETS}" in
|
||||
owasp) GATES="6" ;;
|
||||
jsts|nodegoat|juiceshop) GATES="7" ;;
|
||||
polyglot|railsgoat|dvwa|dvpwa|gosec|rustsec) GATES="8" ;;
|
||||
"") ;; # no --sets: run the requested --gates
|
||||
*) echo "unknown --sets: ${SETS}" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
want_gate() {
|
||||
[[ ",${GATES}," == *",$1,"* ]]
|
||||
}
|
||||
|
||||
# ── Gate 1 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
gate_1_static_corpus() {
|
||||
echo "── Gate 1: static-only scan on tests/benchmark/corpus ──"
|
||||
if [[ ! -d "${REPO_ROOT}/tests/benchmark/corpus" ]]; then
|
||||
echo " SKIP: tests/benchmark/corpus not present"
|
||||
return 0
|
||||
fi
|
||||
cargo run --release --quiet -- scan \
|
||||
--format json \
|
||||
"${REPO_ROOT}/tests/benchmark/corpus" > /tmp/m7_gate1.json
|
||||
echo " PASS: static scan completed"
|
||||
}
|
||||
|
||||
# ── Gate 2 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
gate_2_dynamic_tests() {
|
||||
echo "── Gate 2: cargo nextest run --no-fail-fast --features dynamic ──"
|
||||
cargo nextest run --no-fail-fast --features dynamic
|
||||
# The real-toolchain build-pool perf benches (dynamic_*_build_pool +
|
||||
# dynamic_java_compile_pool) are #[ignore]d so the default inner-loop
|
||||
# suite stays hermetic + fast: no cargo/go/cc/c++/npm/pip/composer/
|
||||
# bundle/javac spawns. Run them explicitly here so CI still exercises
|
||||
# the warm-pool compile path end to end. They self-skip when a
|
||||
# toolchain is missing, so a toolchain-less CI row stays green.
|
||||
cargo nextest run --no-fail-fast --features dynamic --run-ignored ignored-only \
|
||||
-E 'binary(~build_pool) | binary(~compile_pool)'
|
||||
echo " PASS: dynamic test suite green"
|
||||
}
|
||||
|
||||
# ── Gate 3: with-verify / static-only ratio ───────────────────────────────────
|
||||
|
||||
# Phase 23 target: ratio ≤ 1.5×, now that the cross-lang build pools
|
||||
# give every shipped language a warm cache (was ≤ 2× under Phase 22).
|
||||
GATE3_RATIO_TARGET="${GATE3_RATIO_TARGET:-1.5}"
|
||||
|
||||
gate_3_verify_ratio() {
|
||||
echo "── Gate 3: with-verify / static-only ratio on benches/fixtures/ ──"
|
||||
local fixtures="${REPO_ROOT}/benches/fixtures"
|
||||
if [[ ! -d "${fixtures}" ]]; then
|
||||
echo " SKIP: ${fixtures} not present"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Phase 23: the warm build pools are what buy the ≤ 1.5× ratio, so
|
||||
# make sure they are on for both scans even if the caller's env
|
||||
# disabled them. Default is already ON for every shipped language.
|
||||
export NYX_DYNAMIC_BUILD_POOL="java=1,node=1,python=1,php=1,ruby=1,go=1,rust=1,c=1,cpp=1"
|
||||
|
||||
local static_seconds verify_seconds
|
||||
static_seconds="$(time_scan "${fixtures}" 0)"
|
||||
verify_seconds="$(time_scan "${fixtures}" 1)"
|
||||
local ratio
|
||||
ratio="$(awk -v v="${verify_seconds}" -v s="${static_seconds}" \
|
||||
'BEGIN { if (s <= 0) { print "inf"; exit } printf "%.3f", v / s }')"
|
||||
|
||||
echo " static-only wall-clock: ${static_seconds}s"
|
||||
echo " with-verify wall-clock: ${verify_seconds}s"
|
||||
echo " ratio: ${ratio} (target ≤ ${GATE3_RATIO_TARGET})"
|
||||
|
||||
awk -v r="${ratio}" -v t="${GATE3_RATIO_TARGET}" \
|
||||
'BEGIN { if (r+0 > t+0) exit 1 }' \
|
||||
|| { echo " FAIL: ratio exceeds target"; return 1; }
|
||||
echo " PASS"
|
||||
}
|
||||
|
||||
# Print wall-clock seconds for a single scan run.
|
||||
# $1 = path to scan
|
||||
# $2 = 0 for static-only, 1 for --verify
|
||||
time_scan() {
|
||||
local path="$1" verify="$2"
|
||||
local args=("--format" "json")
|
||||
if [[ "${verify}" == "1" ]]; then
|
||||
args+=("--verify")
|
||||
fi
|
||||
args+=("${path}")
|
||||
local start end
|
||||
start="$(python3 -c 'import time;print(time.monotonic())')"
|
||||
cargo run --release --quiet --features dynamic -- scan "${args[@]}" > /dev/null
|
||||
end="$(python3 -c 'import time;print(time.monotonic())')"
|
||||
awk -v a="${start}" -v b="${end}" 'BEGIN { printf "%.3f", b - a }'
|
||||
}
|
||||
|
||||
# ── Gate 4 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
gate_4_sarif_schema() {
|
||||
echo "── Gate 4: SARIF schema validation ──"
|
||||
cargo nextest run --no-fail-fast --features dynamic --test sarif_dynamic_verdict_tests
|
||||
echo " PASS"
|
||||
}
|
||||
|
||||
# ── Gate 5 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
gate_5_layering() {
|
||||
echo "── Gate 5: dynamic layering boundary ──"
|
||||
cargo nextest run --no-fail-fast --features dynamic --test dynamic_layering
|
||||
echo " PASS"
|
||||
}
|
||||
|
||||
# ── Gate 6: Java OWASP-scale ratio ────────────────────────────────────────────
|
||||
|
||||
# Phase 22 + Phase 27 jointly own this gate. The wall-clock budgets
|
||||
# are split: 10 min on the dev reference (M1 macOS w/ JDK 21) and 15
|
||||
# min in CI. Override `NYX_OWASP_WALLCLOCK_BUDGET_SECONDS` to tighten.
|
||||
GATE6_WALLCLOCK_BUDGET="${NYX_OWASP_WALLCLOCK_BUDGET_SECONDS:-900}"
|
||||
GATE6_CONFIRMED_RATE_TARGET="${NYX_OWASP_CONFIRMED_RATE_TARGET:-0.40}"
|
||||
# Phase 27 acceptance: per-cap precision >= 0.85, recall >= 0.40.
|
||||
GATE6_PRECISION_TARGET="${NYX_OWASP_PRECISION_TARGET:-0.85}"
|
||||
GATE6_RECALL_TARGET="${NYX_OWASP_RECALL_TARGET:-0.40}"
|
||||
# Per-cap confirmation floors (confirmed-rate / precision / recall) are
|
||||
# HARD-enforced only for the caps named here; every cap is still measured and
|
||||
# its numbers published either way. Empty = report-only (publish the per-cap
|
||||
# table, fail nothing on those three metrics) while the verifier still cannot
|
||||
# Confirm OWASP findings end to end: today every BenchmarkTest servlet harness
|
||||
# lands in Inconclusive(BuildFailed) or Inconclusive(SpecDerivationFailed)
|
||||
# (Java servlet entry + classpath are Track L.12 / Track O.0 work), so 0 caps
|
||||
# meet the 40% / 85% / 40% headline. The gate therefore enforces what the
|
||||
# verifier already satisfies — wall-clock, no false confirms, the per-cell
|
||||
# budget — and publishes the unmet detection/confirmation numbers as the
|
||||
# ratchet's destination. Set NYX_OWASP_FLOOR_CAPS (e.g. "sqli,cmdi") to
|
||||
# hard-gate a cap the moment it starts Confirming.
|
||||
GATE6_FLOOR_CAPS="${NYX_OWASP_FLOOR_CAPS:-}"
|
||||
GATE6_BUDGET="${NYX_OWASP_BUDGET:-${REPO_ROOT}/tests/eval_corpus/budget.toml}"
|
||||
|
||||
gate_6_owasp_scale() {
|
||||
echo "── Gate 6: Java OWASP Benchmark v1.2 verify wall-clock + confirmed-rate ──"
|
||||
local corpus="${NYX_OWASP_CORPUS:-}"
|
||||
if [[ -z "${corpus}" || ! -d "${corpus}" ]]; then
|
||||
echo " SKIP: set NYX_OWASP_CORPUS to a v1.2 checkout to run this gate."
|
||||
echo " (Gate 6 is Phase 22's headline acceptance for the warm javac daemon.)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local scan_report="/tmp/m7_gate6_scan.json"
|
||||
local results_report="/tmp/m7_gate6_results.json"
|
||||
local wallclock_report="/tmp/m7_gate6_wallclock.txt"
|
||||
local gate_home="${TMPDIR:-/tmp}/nyx_m7_gate6_home"
|
||||
local gate_build_pool="${TMPDIR:-/tmp}/nyx_m7_gate6_build_pool"
|
||||
local wallclock
|
||||
|
||||
cargo build --release --quiet --features dynamic
|
||||
mkdir -p "${gate_home}" "${gate_build_pool}"
|
||||
rm -f "${scan_report}" "${results_report}" "${wallclock_report}"
|
||||
|
||||
set +e
|
||||
HOME="${gate_home}" \
|
||||
NYX_BUILD_POOL_DIR="${gate_build_pool}" \
|
||||
python3 - "${GATE6_WALLCLOCK_BUDGET}" "${scan_report}" "${wallclock_report}" \
|
||||
"${REPO_ROOT}/target/release/nyx" scan \
|
||||
--verify \
|
||||
--index off \
|
||||
--format json \
|
||||
--quiet \
|
||||
"${corpus}" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
budget = float(sys.argv[1])
|
||||
scan_report = sys.argv[2]
|
||||
wallclock_report = sys.argv[3]
|
||||
cmd = sys.argv[4:]
|
||||
start = time.monotonic()
|
||||
rc = 0
|
||||
try:
|
||||
with open(scan_report, "wb") as out:
|
||||
completed = subprocess.run(cmd, stdout=out, timeout=budget)
|
||||
rc = completed.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
rc = 124
|
||||
finally:
|
||||
elapsed = time.monotonic() - start
|
||||
with open(wallclock_report, "w") as f:
|
||||
f.write(f"{elapsed:.1f}\n")
|
||||
sys.exit(rc)
|
||||
PY
|
||||
local nyx_exit=$?
|
||||
set -e
|
||||
wallclock="$(cat "${wallclock_report}" 2>/dev/null || printf "%s" "${GATE6_WALLCLOCK_BUDGET}")"
|
||||
|
||||
echo " OWASP verify wall-clock: ${wallclock}s (budget ${GATE6_WALLCLOCK_BUDGET}s)"
|
||||
|
||||
if [[ ${nyx_exit} -eq 124 ]]; then
|
||||
echo " FAIL: nyx scan exceeded wall-clock budget"
|
||||
return 1
|
||||
fi
|
||||
if [[ ${nyx_exit} -ne 0 && ${nyx_exit} -ne 1 ]]; then
|
||||
echo " FAIL: nyx scan exited ${nyx_exit}"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -s "${scan_report}" ]]; then
|
||||
echo " FAIL: nyx scan produced no JSON report"
|
||||
return 1
|
||||
fi
|
||||
|
||||
awk -v w="${wallclock}" -v b="${GATE6_WALLCLOCK_BUDGET}" \
|
||||
'BEGIN { if (w+0 > b+0) exit 1 }' \
|
||||
|| { echo " FAIL: wall-clock exceeds budget"; return 1; }
|
||||
|
||||
echo "[]" > "${results_report}"
|
||||
# --static buckets a command-injection finding that carries only the
|
||||
# SHELL_ESCAPE sink cap (the static, unconfirmed cmdi class for every
|
||||
# language) as `cmdi` instead of `other`. Without a dynamic Confirm the
|
||||
# SHELL_ESCAPE→CODE_EXEC remap never runs (Java servlet harnesses build-
|
||||
# fail in CI), so the default lens leaves every cmdi finding in `other`
|
||||
# and reads the cmdi cell as 0/0/N; the static lens is the correct
|
||||
# bucketing for an unconfirmed scan and is appended at lowest priority so
|
||||
# no higher-priority cap cell changes.
|
||||
python3 "${REPO_ROOT}/tests/eval_corpus/tabulate.py" \
|
||||
--static \
|
||||
--label owasp \
|
||||
--scan "${scan_report}" \
|
||||
--ground-truth "${REPO_ROOT}/tests/eval_corpus/ground_truth/owasp_benchmark_v1.2.json" \
|
||||
--append "${results_report}" \
|
||||
|| { echo " FAIL: OWASP result tabulation failed"; return 1; }
|
||||
|
||||
local -a report_args=(
|
||||
--results "${results_report}"
|
||||
--budget "${GATE6_BUDGET}"
|
||||
)
|
||||
if [[ -n "${GATE6_FLOOR_CAPS}" ]]; then
|
||||
report_args+=(
|
||||
--floor-caps "${GATE6_FLOOR_CAPS}"
|
||||
--min-confirmed-rate "${GATE6_CONFIRMED_RATE_TARGET}"
|
||||
--min-precision "${GATE6_PRECISION_TARGET}"
|
||||
--min-recall "${GATE6_RECALL_TARGET}"
|
||||
)
|
||||
echo " enforcing per-cap floors (confirmed >= ${GATE6_CONFIRMED_RATE_TARGET}, precision >= ${GATE6_PRECISION_TARGET}, recall >= ${GATE6_RECALL_TARGET}) on: ${GATE6_FLOOR_CAPS}"
|
||||
else
|
||||
echo " per-cap confirmed/precision/recall: report-only (NYX_OWASP_FLOOR_CAPS unset; no cap Confirms OWASP yet)"
|
||||
fi
|
||||
python3 "${REPO_ROOT}/tests/eval_corpus/report.py" "${report_args[@]}" \
|
||||
|| { echo " FAIL: OWASP per-cell budget exceeded or a gated per-cap floor missed"; return 1; }
|
||||
echo " PASS"
|
||||
}
|
||||
|
||||
# ── Shared real-corpus acceptance runner (Gates 7 + 8) ────────────────────────
|
||||
|
||||
# Run one real-corpus `--verify` row: scan under a wall-clock guard,
|
||||
# tabulate against the committed ground truth, enforce the per-cell budget,
|
||||
# publish (or, when floor caps are set, enforce) the per-cap floors. Every
|
||||
# random source nyx uses is seeded from spec_hash, so reruns are
|
||||
# deterministic. Generic across gates — all gate-specific knobs are passed
|
||||
# in so Gate 7 (JS/TS) and Gate 8 (polyglot) share one code path.
|
||||
# $1 label $2 corpus dir $3 ground-truth json
|
||||
# $4 wallclock(s) $5 budget.toml $6 floor caps (may be empty)
|
||||
# $7 confirmed target $8 precision target $9 recall target
|
||||
# $10 floor-unset hint (e.g. "NYX_POLYGLOT_FLOOR_CAPS unset")
|
||||
# $11 lang filter (may be empty) — scope tabulation to one language so
|
||||
# incidental other-language assets (vendored JS in a Rails/aiohttp app)
|
||||
# do not pollute the corpus's per-cap metrics
|
||||
# Returns 0 on pass, 1 on fail. Caller decides skip.
|
||||
_run_corpus_acceptance() {
|
||||
local label="$1" corpus="$2" gt="$3" wallclock_budget="$4" budget_file="$5"
|
||||
local floor_caps="$6" confirmed_target="$7" precision_target="$8"
|
||||
local recall_target="$9" floor_hint="${10}" lang_filter="${11:-}"
|
||||
local scan_report="/tmp/m7_corpus_${label}_scan.json"
|
||||
local results_report="/tmp/m7_corpus_${label}_results.json"
|
||||
local wallclock_report="/tmp/m7_corpus_${label}_wallclock.txt"
|
||||
local gate_home="${TMPDIR:-/tmp}/nyx_m7_corpus_${label}_home"
|
||||
local gate_build_pool="${TMPDIR:-/tmp}/nyx_m7_corpus_${label}_build_pool"
|
||||
local wallclock
|
||||
|
||||
mkdir -p "${gate_home}" "${gate_build_pool}"
|
||||
rm -f "${scan_report}" "${results_report}" "${wallclock_report}"
|
||||
|
||||
set +e
|
||||
HOME="${gate_home}" \
|
||||
NYX_BUILD_POOL_DIR="${gate_build_pool}" \
|
||||
python3 - "${wallclock_budget}" "${scan_report}" "${wallclock_report}" \
|
||||
"${REPO_ROOT}/target/release/nyx" scan \
|
||||
--verify \
|
||||
--index off \
|
||||
--format json \
|
||||
--quiet \
|
||||
"${corpus}" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
budget = float(sys.argv[1])
|
||||
scan_report = sys.argv[2]
|
||||
wallclock_report = sys.argv[3]
|
||||
cmd = sys.argv[4:]
|
||||
start = time.monotonic()
|
||||
rc = 0
|
||||
try:
|
||||
with open(scan_report, "wb") as out:
|
||||
completed = subprocess.run(cmd, stdout=out, timeout=budget)
|
||||
rc = completed.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
rc = 124
|
||||
finally:
|
||||
elapsed = time.monotonic() - start
|
||||
with open(wallclock_report, "w") as f:
|
||||
f.write(f"{elapsed:.1f}\n")
|
||||
sys.exit(rc)
|
||||
PY
|
||||
local nyx_exit=$?
|
||||
set -e
|
||||
wallclock="$(cat "${wallclock_report}" 2>/dev/null || printf "%s" "${wallclock_budget}")"
|
||||
|
||||
echo " ${label} verify wall-clock: ${wallclock}s (budget ${wallclock_budget}s)"
|
||||
|
||||
if [[ ${nyx_exit} -eq 124 ]]; then
|
||||
echo " FAIL: ${label} scan exceeded wall-clock budget"
|
||||
return 1
|
||||
fi
|
||||
if [[ ${nyx_exit} -ne 0 && ${nyx_exit} -ne 1 ]]; then
|
||||
echo " FAIL: ${label} scan exited ${nyx_exit}"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -s "${scan_report}" ]]; then
|
||||
echo " FAIL: ${label} scan produced no JSON report"
|
||||
return 1
|
||||
fi
|
||||
awk -v w="${wallclock}" -v b="${wallclock_budget}" \
|
||||
'BEGIN { if (w+0 > b+0) exit 1 }' \
|
||||
|| { echo " FAIL: ${label} wall-clock exceeds budget"; return 1; }
|
||||
|
||||
echo "[]" > "${results_report}"
|
||||
# --static: bucket SHELL_ESCAPE-only command-injection findings as `cmdi`
|
||||
# (see the Gate 6 note) so the per-cap table reflects the engine's real
|
||||
# static classification in CI where no dynamic Confirm runs the
|
||||
# SHELL_ESCAPE→CODE_EXEC remap. Appended at lowest priority; no other cap
|
||||
# cell changes.
|
||||
local -a tabulate_args=(
|
||||
--static
|
||||
--label "${label}"
|
||||
--scan "${scan_report}"
|
||||
--ground-truth "${gt}"
|
||||
--append "${results_report}"
|
||||
)
|
||||
if [[ -n "${lang_filter}" ]]; then
|
||||
tabulate_args+=(--lang "${lang_filter}")
|
||||
echo " scoping tabulation to language(s): ${lang_filter}"
|
||||
fi
|
||||
python3 "${REPO_ROOT}/tests/eval_corpus/tabulate.py" "${tabulate_args[@]}" \
|
||||
|| { echo " FAIL: ${label} result tabulation failed"; return 1; }
|
||||
|
||||
local -a report_args=(
|
||||
--results "${results_report}"
|
||||
--budget "${budget_file}"
|
||||
)
|
||||
if [[ -n "${floor_caps}" ]]; then
|
||||
report_args+=(
|
||||
--floor-caps "${floor_caps}"
|
||||
--min-confirmed-rate "${confirmed_target}"
|
||||
--min-precision "${precision_target}"
|
||||
--min-recall "${recall_target}"
|
||||
)
|
||||
echo " enforcing per-cap floors (confirmed >= ${confirmed_target}, precision >= ${precision_target}, recall >= ${recall_target}) on: ${floor_caps}"
|
||||
else
|
||||
echo " per-cap confirmed/precision/recall: report-only (${floor_hint})"
|
||||
fi
|
||||
python3 "${REPO_ROOT}/tests/eval_corpus/report.py" "${report_args[@]}" \
|
||||
|| { echo " FAIL: ${label} per-cell budget exceeded or a gated per-cap floor missed"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Gate 7: JS/TS real-corpus acceptance (NodeGoat + Juice Shop) ──────────────
|
||||
|
||||
# Phase 28 (Track R.1) mirror of Gate 6 for the JS/TS corpora. Same
|
||||
# wall-clock split (10 min dev reference / 15 min CI) and the same
|
||||
# report-only-by-default floor policy: NYX_JSTS_FLOOR_CAPS is empty, so the
|
||||
# per-cap confirmed-rate / precision / recall numbers are published but gate
|
||||
# nothing, while the per-(cap,lang) budget (unsupported_rate,
|
||||
# false_confirmed_rate) is hard-enforced. Promote a cap into the floor set
|
||||
# once it starts Confirming end to end.
|
||||
GATE7_WALLCLOCK_BUDGET="${NYX_JSTS_WALLCLOCK_BUDGET_SECONDS:-900}"
|
||||
GATE7_CONFIRMED_RATE_TARGET="${NYX_JSTS_CONFIRMED_RATE_TARGET:-0.40}"
|
||||
GATE7_PRECISION_TARGET="${NYX_JSTS_PRECISION_TARGET:-0.85}"
|
||||
GATE7_RECALL_TARGET="${NYX_JSTS_RECALL_TARGET:-0.40}"
|
||||
GATE7_FLOOR_CAPS="${NYX_JSTS_FLOOR_CAPS:-}"
|
||||
GATE7_BUDGET="${NYX_JSTS_BUDGET:-${REPO_ROOT}/tests/eval_corpus/budget.toml}"
|
||||
|
||||
gate_7_jsts_scale() {
|
||||
echo "── Gate 7: JS/TS real-corpus (NodeGoat + Juice Shop) verify acceptance ──"
|
||||
cargo build --release --quiet --features dynamic
|
||||
|
||||
# name : env var holding the corpus dir : committed ground-truth file
|
||||
local rows=(
|
||||
"nodegoat:NYX_NODEGOAT_CORPUS:nodegoat.json"
|
||||
"juiceshop:NYX_JUICESHOP_CORPUS:juiceshop.json"
|
||||
)
|
||||
local any_ran=0 any_failed=0
|
||||
for row in "${rows[@]}"; do
|
||||
local name envvar gtfile
|
||||
IFS=: read -r name envvar gtfile <<<"${row}"
|
||||
# When --sets names a single corpus, only run that row.
|
||||
if [[ -n "${SETS}" && "${SETS}" != "jsts" && "${SETS}" != "${name}" ]]; then
|
||||
continue
|
||||
fi
|
||||
local corpus="${!envvar:-}"
|
||||
if [[ -z "${corpus}" || ! -d "${corpus}" ]]; then
|
||||
echo " SKIP ${name}: set ${envvar} to a checkout to run this row."
|
||||
continue
|
||||
fi
|
||||
any_ran=1
|
||||
echo " ── ${name} (${corpus}) ──"
|
||||
# No --lang scope: NodeGoat/Juice Shop are single-language (js/ts), so
|
||||
# there is no cross-language asset noise to filter (unchanged Gate 7).
|
||||
if _run_corpus_acceptance "${name}" "${corpus}" \
|
||||
"${REPO_ROOT}/tests/eval_corpus/ground_truth/${gtfile}" \
|
||||
"${GATE7_WALLCLOCK_BUDGET}" "${GATE7_BUDGET}" "${GATE7_FLOOR_CAPS}" \
|
||||
"${GATE7_CONFIRMED_RATE_TARGET}" "${GATE7_PRECISION_TARGET}" \
|
||||
"${GATE7_RECALL_TARGET}" "NYX_JSTS_FLOOR_CAPS unset" ""; then
|
||||
echo " PASS ${name}"
|
||||
else
|
||||
any_failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${any_ran} -eq 0 ]]; then
|
||||
echo " SKIP: no JS/TS corpus configured (set NYX_NODEGOAT_CORPUS / NYX_JUICESHOP_CORPUS)."
|
||||
echo " (Gate 7 is Phase 28's headline acceptance for the JS/TS real corpora.)"
|
||||
return 0
|
||||
fi
|
||||
[[ ${any_failed} -eq 0 ]] || return 1
|
||||
echo " PASS"
|
||||
}
|
||||
|
||||
# ── Gate 8: Polyglot real-corpus acceptance (Track R.2 / Phase 29) ────────────
|
||||
|
||||
# RailsGoat (Rails, .rb) + DVWA (PHP) + DVPWA (aiohttp, .py) + gosec (Go) +
|
||||
# the RustSec advisory-db (Rust negative control). Same wall-clock split and
|
||||
# the same report-only-by-default floor policy as Gates 6/7: the per-(cap,lang)
|
||||
# budget in tests/eval_corpus/budget.toml is hard-enforced, while per-cap
|
||||
# confirmed-rate / precision / recall are published but gate nothing until
|
||||
# NYX_POLYGLOT_FLOOR_CAPS names a cap. Each row self-skips unless its
|
||||
# corpus env var points at a real checkout. The RustSec row is a NEGATIVE
|
||||
# CONTROL: advisory-db ships advisory metadata, not vulnerable source, so its
|
||||
# ground truth is empty by construction and the row asserts nyx Confirms
|
||||
# nothing there (false_confirmed_rate guard).
|
||||
GATE8_WALLCLOCK_BUDGET="${NYX_POLYGLOT_WALLCLOCK_BUDGET_SECONDS:-900}"
|
||||
GATE8_CONFIRMED_RATE_TARGET="${NYX_POLYGLOT_CONFIRMED_RATE_TARGET:-0.40}"
|
||||
GATE8_PRECISION_TARGET="${NYX_POLYGLOT_PRECISION_TARGET:-0.85}"
|
||||
GATE8_RECALL_TARGET="${NYX_POLYGLOT_RECALL_TARGET:-0.40}"
|
||||
GATE8_FLOOR_CAPS="${NYX_POLYGLOT_FLOOR_CAPS:-}"
|
||||
GATE8_BUDGET="${NYX_POLYGLOT_BUDGET:-${REPO_ROOT}/tests/eval_corpus/budget.toml}"
|
||||
|
||||
gate_8_polyglot_scale() {
|
||||
echo "── Gate 8: polyglot real-corpus (RailsGoat/DVWA/DVPWA/gosec/RustSec) verify acceptance ──"
|
||||
cargo build --release --quiet --features dynamic
|
||||
|
||||
# name : env var holding the corpus dir : committed ground-truth file :
|
||||
# target language (tabulation is scoped to it so incidental other-language
|
||||
# assets — e.g. vendored JS in the Rails / aiohttp apps — do not pollute
|
||||
# the corpus's per-cap metrics).
|
||||
local rows=(
|
||||
"railsgoat:NYX_RAILSGOAT_CORPUS:railsgoat.json:ruby"
|
||||
"dvwa:NYX_DVWA_CORPUS:dvwa.json:php"
|
||||
"dvpwa:NYX_DVPWA_CORPUS:dvpwa.json:python"
|
||||
"gosec:NYX_GOSEC_CORPUS:gosec.json:go"
|
||||
"rustsec:NYX_RUSTSEC_CORPUS:rustsec.json:rust"
|
||||
)
|
||||
local any_ran=0 any_failed=0
|
||||
for row in "${rows[@]}"; do
|
||||
local name envvar gtfile lang
|
||||
IFS=: read -r name envvar gtfile lang <<<"${row}"
|
||||
# When --sets names a single corpus, only run that row.
|
||||
if [[ -n "${SETS}" && "${SETS}" != "polyglot" && "${SETS}" != "${name}" ]]; then
|
||||
continue
|
||||
fi
|
||||
local corpus="${!envvar:-}"
|
||||
if [[ -z "${corpus}" || ! -d "${corpus}" ]]; then
|
||||
echo " SKIP ${name}: set ${envvar} to a checkout to run this row."
|
||||
continue
|
||||
fi
|
||||
any_ran=1
|
||||
echo " ── ${name} (${corpus}) ──"
|
||||
if _run_corpus_acceptance "${name}" "${corpus}" \
|
||||
"${REPO_ROOT}/tests/eval_corpus/ground_truth/${gtfile}" \
|
||||
"${GATE8_WALLCLOCK_BUDGET}" "${GATE8_BUDGET}" "${GATE8_FLOOR_CAPS}" \
|
||||
"${GATE8_CONFIRMED_RATE_TARGET}" "${GATE8_PRECISION_TARGET}" \
|
||||
"${GATE8_RECALL_TARGET}" "NYX_POLYGLOT_FLOOR_CAPS unset" "${lang}"; then
|
||||
echo " PASS ${name}"
|
||||
else
|
||||
any_failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${any_ran} -eq 0 ]]; then
|
||||
echo " SKIP: no polyglot corpus configured (set NYX_RAILSGOAT_CORPUS /"
|
||||
echo " NYX_DVWA_CORPUS / NYX_DVPWA_CORPUS / NYX_GOSEC_CORPUS / NYX_RUSTSEC_CORPUS)."
|
||||
echo " (Gate 8 is Phase 29's headline acceptance for the polyglot real corpora.)"
|
||||
return 0
|
||||
fi
|
||||
[[ ${any_failed} -eq 0 ]] || return 1
|
||||
echo " PASS"
|
||||
}
|
||||
|
||||
# ── Driver ────────────────────────────────────────────────────────────────────
|
||||
|
||||
declare -a FAILED=()
|
||||
run_gate() {
|
||||
local idx="$1" name="$2"
|
||||
if want_gate "${idx}"; then
|
||||
if ! "gate_${idx}_${name}"; then
|
||||
FAILED+=("${idx}")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_gate 1 static_corpus
|
||||
run_gate 2 dynamic_tests
|
||||
run_gate 3 verify_ratio
|
||||
run_gate 4 sarif_schema
|
||||
run_gate 5 layering
|
||||
run_gate 6 owasp_scale
|
||||
run_gate 7 jsts_scale
|
||||
run_gate 8 polyglot_scale
|
||||
|
||||
if [[ ${#FAILED[@]} -gt 0 ]]; then
|
||||
echo
|
||||
echo "FAILED gates: ${FAILED[*]}"
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
echo "All requested gates passed."
|
||||
48
scripts/update_dynamic_goldens.sh
Executable file
48
scripts/update_dynamic_goldens.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regenerate dynamic-fixture golden verdicts.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/update_dynamic_goldens.sh [--test <name>]
|
||||
#
|
||||
# Re-runs the dynamic fixture suites under `NYX_UPDATE_GOLDENS=1` so each
|
||||
# fixture's harness overwrites its `.golden.json` file with the current
|
||||
# verdict. After this script completes, rerun without the env var to
|
||||
# confirm the goldens match.
|
||||
#
|
||||
# Default: refreshes both python_fixtures and rust_fixtures. Pass --test
|
||||
# to refresh only one suite (e.g. `--test python_fixtures`).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
SUITES=(python_fixtures rust_fixtures)
|
||||
if [[ $# -gt 0 ]]; then
|
||||
case "$1" in
|
||||
--test) SUITES=("$2"); shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '2,12p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown arg: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
for suite in "${SUITES[@]}"; do
|
||||
echo "[update-goldens] refreshing $suite ..."
|
||||
NYX_UPDATE_GOLDENS=1 \
|
||||
cargo nextest run --features dynamic --test "$suite" --no-fail-fast
|
||||
done
|
||||
|
||||
echo "[update-goldens] re-running suites without NYX_UPDATE_GOLDENS=1 to verify ..."
|
||||
for suite in "${SUITES[@]}"; do
|
||||
cargo nextest run --features dynamic --test "$suite"
|
||||
done
|
||||
|
||||
echo "[update-goldens] done. Inspect git diff under tests/dynamic_fixtures/ before committing."
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
//! Tracks inclusive `[lo, hi]` integer bounds. `None` = unbounded (−∞ or +∞).
|
||||
//! Both `None` = Top (any integer). Provides arithmetic transfer functions
|
||||
//! (add, sub, mul, div, mod) with overflow-safe semantics.
|
||||
#![allow(clippy::collapsible_if)]
|
||||
|
||||
use crate::state::lattice::{AbstractDomain, Lattice};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
|||
159
src/ast.rs
159
src/ast.rs
|
|
@ -102,6 +102,7 @@ fn parse_timeout_diag(path: &Path, timeout_ms: u64) -> Diag {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -234,10 +235,17 @@ fn build_taint_diag(
|
|||
.map(sanitize_desc)
|
||||
})
|
||||
.unwrap_or_else(|| "(unknown)".into());
|
||||
// Sink-callee attribution: when the sink node is an *argument* of a call
|
||||
// (e.g. PHP `header("location: " . $_GET['x'])` — the `$_GET[...]` subscript
|
||||
// carries `callee = "$_GET"` but `outer_callee = "header"`), the enclosing
|
||||
// call is the real sink and should be displayed, not the source token.
|
||||
// `outer_callee` is only populated for nested/argument positions, so for a
|
||||
// plain call node it is None and we fall back to the node's own callee.
|
||||
let call_site_callee = cfg_graph[finding.sink]
|
||||
.call
|
||||
.callee
|
||||
.outer_callee
|
||||
.as_deref()
|
||||
.or(cfg_graph[finding.sink].call.callee.as_deref())
|
||||
.map(sanitize_desc)
|
||||
.unwrap_or_else(|| "(unknown)".into());
|
||||
let kind_label = source_kind_label(finding.source_kind);
|
||||
|
|
@ -706,6 +714,7 @@ fn build_taint_diag(
|
|||
rollup: None,
|
||||
finding_id: finding.finding_id.clone(),
|
||||
alternative_finding_ids: finding.alternative_finding_ids.to_vec(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
|
||||
// Post-fill explanation and confidence limiters
|
||||
|
|
@ -779,6 +788,35 @@ fn lang_for_path(path: &Path) -> Option<(Language, &'static str)> {
|
|||
}
|
||||
}
|
||||
|
||||
/// All language slugs the scanner can parse, paired with the file extensions
|
||||
/// that map to them. Single source of truth shared with [`lang_for_path`]; the
|
||||
/// `supported_extensions_resolve_to_their_slug` test asserts they stay in sync.
|
||||
pub(crate) const SUPPORTED_LANGUAGE_EXTENSIONS: &[(&str, &[&str])] = &[
|
||||
("rust", &["rs"]),
|
||||
("c", &["c"]),
|
||||
(
|
||||
"cpp",
|
||||
&["cpp", "cc", "cxx", "c++", "hpp", "hxx", "hh", "h++"],
|
||||
),
|
||||
("java", &["java"]),
|
||||
("go", &["go"]),
|
||||
("php", &["php"]),
|
||||
("python", &["py"]),
|
||||
("typescript", &["ts", "tsx"]),
|
||||
("javascript", &["js", "jsx"]),
|
||||
("ruby", &["rb"]),
|
||||
];
|
||||
|
||||
/// File extensions associated with a language slug (case-insensitive). Returns
|
||||
/// an empty slice if `slug` is not a supported language.
|
||||
pub fn extensions_for_lang(slug: &str) -> &'static [&'static str] {
|
||||
SUPPORTED_LANGUAGE_EXTENSIONS
|
||||
.iter()
|
||||
.find(|(s, _)| s.eq_ignore_ascii_case(slug))
|
||||
.map(|(_, exts)| *exts)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Fast binary-file guard: skip if >1% NUL bytes.
|
||||
fn is_binary(bytes: &[u8]) -> bool {
|
||||
bytes.iter().filter(|b| **b == 0).count() * 100 / bytes.len().max(1) > 1
|
||||
|
|
@ -965,9 +1003,11 @@ fn is_test_suppressible_pattern(id: &str) -> bool {
|
|||
// deterministic test data, insecure RNG used for fixture seeding.
|
||||
id.ends_with(".secrets.hardcoded_secret")
|
||||
|| id.ends_with(".secrets.hardcoded_key")
|
||||
|| id.ends_with(".crypto.hardcoded_key")
|
||||
|| id.ends_with(".crypto.math_random")
|
||||
|| id.ends_with(".crypto.insecure_random")
|
||||
|| id.ends_with(".crypto.weak_digest")
|
||||
|| id.ends_with(".crypto.weak_algorithm")
|
||||
|| id.ends_with(".crypto.md5")
|
||||
|| id.ends_with(".crypto.sha1")
|
||||
|| id.ends_with(".crypto.rand")
|
||||
|
|
@ -1041,9 +1081,7 @@ fn downgrade_severity(s: Severity) -> Severity {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ParsedSource + ParsedFile: shared parse/CFG pipeline
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Level 1: parsed tree + lang info. No CFG construction.
|
||||
struct ParsedSource<'a> {
|
||||
|
|
@ -1363,6 +1401,7 @@ impl<'a> ParsedSource<'a> {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1890,7 +1929,6 @@ impl<'a> ParsedFile<'a> {
|
|||
cfg: &body.graph,
|
||||
entry: body.entry,
|
||||
lang: caller_lang,
|
||||
file_path: &self.source.file_path_str,
|
||||
source_bytes: self.source.bytes,
|
||||
func_summaries: self.local_summaries(),
|
||||
global_summaries,
|
||||
|
|
@ -1950,13 +1988,35 @@ impl<'a> ParsedFile<'a> {
|
|||
cfg_analysis::Confidence::Medium => crate::evidence::Confidence::Medium,
|
||||
cfg_analysis::Confidence::Low => crate::evidence::Confidence::Low,
|
||||
});
|
||||
// Carry the sink node's resolved Sink caps onto the structural
|
||||
// finding's evidence so downstream cap-classification (and the
|
||||
// eval `cap_of`) buckets `cfg-unguarded-sink` under its real cap
|
||||
// (sqli/cmdi/ssrf/…) instead of the catch-all `other`. Without
|
||||
// this every taint-less structural sink finding fell through to
|
||||
// `other`, hiding real recall (e.g. dvpwa `cur.execute` SQLi)
|
||||
// and inflating the `other` bucket. Non-sink structural findings
|
||||
// (resource-leak, auth-gap) carry no Sink label, so this is 0.
|
||||
let cf_sink_caps: u32 = cf
|
||||
.evidence
|
||||
.first()
|
||||
.map(|&n| {
|
||||
cfg_ctx.cfg[n].taint.labels.iter().fold(0u32, |acc, l| {
|
||||
if let crate::labels::DataLabel::Sink(c) = l {
|
||||
acc | c.bits()
|
||||
} else {
|
||||
acc
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let cf_category = FindingCategory::for_structural_rule(&cf.rule_id);
|
||||
out.push(Diag {
|
||||
path: self.source.path.to_string_lossy().into_owned(),
|
||||
line: point.row + 1,
|
||||
col: point.column + 1,
|
||||
severity: cf.severity,
|
||||
id: cf.rule_id,
|
||||
category: FindingCategory::Security,
|
||||
category: cf_category,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: Some(cf.message),
|
||||
|
|
@ -1971,6 +2031,7 @@ impl<'a> ParsedFile<'a> {
|
|||
kind: "sink".into(),
|
||||
snippet: None,
|
||||
}),
|
||||
sink_caps: cf_sink_caps,
|
||||
guards: vec![],
|
||||
sanitizers: vec![],
|
||||
state: None,
|
||||
|
|
@ -1984,6 +2045,7 @@ impl<'a> ParsedFile<'a> {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
});
|
||||
}
|
||||
} // end for body in bodies (CFG structural analyses)
|
||||
|
|
@ -2031,7 +2093,7 @@ impl<'a> ParsedFile<'a> {
|
|||
col: point.column + 1,
|
||||
severity: sf.severity,
|
||||
id: sf.rule_id.clone(),
|
||||
category: FindingCategory::Security,
|
||||
category: FindingCategory::for_structural_rule(&sf.rule_id),
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: Some(sf.message.clone()),
|
||||
|
|
@ -2064,6 +2126,7 @@ impl<'a> ParsedFile<'a> {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2157,9 +2220,7 @@ impl<'a> ParsedFile<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pass 1: Extract function summaries (no taint analysis)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract function summaries from pre-read bytes.
|
||||
///
|
||||
|
|
@ -2305,7 +2366,10 @@ pub fn perf_stage_breakdown_fused(
|
|||
TaintSuppressionCtx::build(&parsed.file_cfg, &parsed.source.tree, &taint_diags);
|
||||
let _filtered: Vec<_> = ast_findings
|
||||
.into_iter()
|
||||
.filter(|d| !suppression.should_suppress(&d.id, d.line))
|
||||
.filter(|d| {
|
||||
!suppression.should_suppress(&d.id, d.line)
|
||||
&& !suppression.is_redundant_ast_pattern(&d.id, d.line)
|
||||
})
|
||||
.collect();
|
||||
let t_suppr = s_suppr.elapsed().as_micros();
|
||||
|
||||
|
|
@ -2449,9 +2513,7 @@ pub fn extract_all_summaries_from_bytes(
|
|||
))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Constant-argument suppression helper
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns `true` when the captured call node has only literal arguments
|
||||
/// (string, number, boolean, null/nil/none), or identifier arguments that
|
||||
|
|
@ -5351,9 +5413,7 @@ fn has_interpolation(node: tree_sitter::Node) -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Layer B: AST pattern suppression when taint confirms safety
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Map the second segment of a pattern ID (e.g. "cmdi" from "py.cmdi.os_system")
|
||||
/// to the `Cap` that taint analysis models. Returns `None` for categories taint
|
||||
|
|
@ -5425,6 +5485,14 @@ struct TaintSuppressionCtx {
|
|||
/// 11 inline analysis but the sink's enclosing scope has no
|
||||
/// labelled Sanitizer of its own.
|
||||
interproc_sanitizer_callers: HashSet<Option<String>>,
|
||||
/// Union of resolved sink-cap bits for cap-specific taint findings at
|
||||
/// each line. Used by [`Self::is_redundant_ast_pattern`] to drop an
|
||||
/// AST-pattern finding only when the flow engine already emitted a
|
||||
/// specific rule id for the same vulnerability class. Legacy generic
|
||||
/// findings (`taint-unsanitised-flow`, `cfg-unguarded-sink`) are not
|
||||
/// canonical enough to subsume language-specific AST rule IDs such as
|
||||
/// `py.cmdi.subprocess_shell` or `c.cmdi.system`.
|
||||
specific_taint_finding_caps_by_line: HashMap<usize, u32>,
|
||||
}
|
||||
|
||||
impl TaintSuppressionCtx {
|
||||
|
|
@ -5623,6 +5691,26 @@ impl TaintSuppressionCtx {
|
|||
.map(|d| d.line)
|
||||
.collect();
|
||||
|
||||
// Cap bits per line for cap-specific flow-backed findings only, so a
|
||||
// redundant AST pattern at the same line+cap can be dropped in favour
|
||||
// of the richer flow. Do not count legacy generic findings here:
|
||||
// `taint-unsanitised-flow` and `cfg-unguarded-sink` carry evidence,
|
||||
// but their rule ids are deliberately catch-alls, while AST `cmdi`,
|
||||
// `sqli`, etc. IDs are the canonical namespace many tests, SARIF
|
||||
// consumers, and dynamic-verification spec derivation rely on.
|
||||
let mut specific_taint_finding_caps_by_line: HashMap<usize, u32> = HashMap::new();
|
||||
for d in taint_diags {
|
||||
if d.id.starts_with("taint-") && !d.id.starts_with("taint-unsanitised-flow") {
|
||||
if let Some(caps) = d.evidence.as_ref().map(|e| e.sink_caps) {
|
||||
if caps != 0 {
|
||||
*specific_taint_finding_caps_by_line
|
||||
.entry(d.line)
|
||||
.or_default() |= caps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-function partition of taint findings. Maps each finding's
|
||||
// line to the enclosing function scope by reusing
|
||||
// `sink_func_at_line` (the same span/function mapping the Sink-side
|
||||
|
|
@ -5646,9 +5734,30 @@ impl TaintSuppressionCtx {
|
|||
engine_validated_funcs,
|
||||
source_killed_funcs,
|
||||
interproc_sanitizer_callers,
|
||||
specific_taint_finding_caps_by_line,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when an AST pattern finding is a redundant restatement
|
||||
/// of a flow the taint engine already reported at the same line.
|
||||
///
|
||||
/// The taint / structural flow finding carries source + path evidence the
|
||||
/// bare pattern lacks, so when both fire at the same line for the same
|
||||
/// cap the pattern is pure duplicate noise. This is the
|
||||
/// taint-found-it-UNSAFE counterpart to [`Self::should_suppress`]'s
|
||||
/// taint-found-it-SAFE logic: there, no flow finding means the pattern
|
||||
/// may carry unique signal; here, a same-cap flow finding means it does
|
||||
/// not. Cap-matched (not line-only) so a pattern whose cap differs from
|
||||
/// the co-located flow's cap — a genuinely distinct sink — is preserved.
|
||||
fn is_redundant_ast_pattern(&self, pattern_id: &str, line: usize) -> bool {
|
||||
let Some(cap) = pattern_category_cap(pattern_id) else {
|
||||
return false;
|
||||
};
|
||||
self.specific_taint_finding_caps_by_line
|
||||
.get(&line)
|
||||
.is_some_and(|caps| caps & cap.bits() != 0)
|
||||
}
|
||||
|
||||
/// Returns `true` if this AST pattern finding should be suppressed.
|
||||
fn should_suppress(&self, pattern_id: &str, line: usize) -> bool {
|
||||
// Condition 1: pattern category maps to a Cap taint models
|
||||
|
|
@ -5734,9 +5843,7 @@ impl TaintSuppressionCtx {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pass 2 / single‑file: Full rule execution (AST queries + taint)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run all enabled analyses on pre-read bytes and return diagnostics.
|
||||
///
|
||||
|
|
@ -5779,11 +5886,10 @@ pub fn run_rules_on_bytes(
|
|||
let suppression =
|
||||
TaintSuppressionCtx::build(&parsed.file_cfg, &parsed.source.tree, &out);
|
||||
let ast_findings = parsed.source.run_ast_queries(cfg);
|
||||
out.extend(
|
||||
ast_findings
|
||||
.into_iter()
|
||||
.filter(|d| !suppression.should_suppress(&d.id, d.line)),
|
||||
);
|
||||
out.extend(ast_findings.into_iter().filter(|d| {
|
||||
!suppression.should_suppress(&d.id, d.line)
|
||||
&& !suppression.is_redundant_ast_pattern(&d.id, d.line)
|
||||
}));
|
||||
}
|
||||
if cfg.scanner.mode == AnalysisMode::Full {
|
||||
out.extend(parsed.run_auth_analyses(cfg, global_summaries, scan_root));
|
||||
|
|
@ -5812,9 +5918,7 @@ pub fn run_rules_on_file(
|
|||
run_rules_on_bytes(&bytes, path, cfg, global_summaries, scan_root)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fused single-pass: extract summaries + run full analysis in one parse/CFG
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Result of a fused analysis pass: both function summaries and diagnostics.
|
||||
pub struct FusedResult {
|
||||
|
|
@ -5979,11 +6083,10 @@ pub fn analyse_file_fused(
|
|||
if needs_cfg && cfg.scanner.mode == AnalysisMode::Full {
|
||||
let suppression =
|
||||
TaintSuppressionCtx::build(&parsed.file_cfg, &parsed.source.tree, &out);
|
||||
out.extend(
|
||||
ast_findings
|
||||
.into_iter()
|
||||
.filter(|d| !suppression.should_suppress(&d.id, d.line)),
|
||||
);
|
||||
out.extend(ast_findings.into_iter().filter(|d| {
|
||||
!suppression.should_suppress(&d.id, d.line)
|
||||
&& !suppression.is_redundant_ast_pattern(&d.id, d.line)
|
||||
}));
|
||||
} else {
|
||||
out.extend(ast_findings);
|
||||
}
|
||||
|
|
@ -6086,9 +6189,7 @@ pub fn analyse_file_fused(
|
|||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Text-based pattern scanning (non-tree-sitter files)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run text-based pattern scanners on files whose extension is not supported
|
||||
/// by tree-sitter. Currently handles `.ejs` templates.
|
||||
|
|
|
|||
287
src/auth_analysis/auth_markers.rs
Normal file
287
src/auth_analysis/auth_markers.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! Canonical per-framework authentication-marker registry.
|
||||
//!
|
||||
//! Both the Phase 22 surface probes (`src/surface/lang/*.rs`) and the
|
||||
//! auth-analysis recogniser consult this module so a marker that is
|
||||
//! known to one side cannot drift away from the other. Each constant
|
||||
//! is a flat `&[&str]` of identifier shapes that signal a route is
|
||||
//! gated behind authentication; surface probes match the leaf segment
|
||||
//! of a decorator / middleware / extractor identifier
|
||||
//! (case-insensitive), and the auth analyser folds these into its
|
||||
//! per-language `login_guard_names` / `authorization_check_names`
|
||||
//! tables via [`router_auth_markers_for_lang`].
|
||||
//!
|
||||
//! The lists were lifted verbatim from the per-probe constants that
|
||||
//! shipped with Phase 22; further additions land here and propagate to
|
||||
//! every consumer at once.
|
||||
//!
|
||||
//! Lookups: prefer [`is_router_auth_marker`] for the framework-aware
|
||||
//! dispatch, fall back to [`is_known_router_auth_marker`] when the
|
||||
//! framework is not yet identified at the call site.
|
||||
|
||||
use crate::symbol::Lang;
|
||||
|
||||
/// Frameworks the surface probes recognise. Distinct from
|
||||
/// [`crate::surface::Framework`] (which carries pretty-print metadata)
|
||||
/// so this module stays free of surface-layer types and can be
|
||||
/// imported by `auth_analysis::extract` without a circular dep.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum AuthFramework {
|
||||
Flask,
|
||||
FastApi,
|
||||
Django,
|
||||
Spring,
|
||||
JavaServlet,
|
||||
Quarkus,
|
||||
Express,
|
||||
Koa,
|
||||
Gin,
|
||||
ActixWeb,
|
||||
Axum,
|
||||
}
|
||||
|
||||
/// Flask (`@login_required`, `@requires_auth`, …).
|
||||
pub const FLASK_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"auth_required",
|
||||
"jwt_required",
|
||||
"token_required",
|
||||
"requires_auth",
|
||||
"authenticated",
|
||||
"require_login",
|
||||
];
|
||||
|
||||
/// FastAPI (`Depends(get_current_user)`, `@login_required`, …).
|
||||
pub const FASTAPI_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"auth_required",
|
||||
"jwt_required",
|
||||
"token_required",
|
||||
"requires_auth",
|
||||
"authenticated",
|
||||
"require_auth",
|
||||
"require_login",
|
||||
"current_user",
|
||||
];
|
||||
|
||||
/// Django (`@login_required`, `@permission_required`, …).
|
||||
pub const DJANGO_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"permission_required",
|
||||
"user_passes_test",
|
||||
"staff_member_required",
|
||||
"csrf_protect",
|
||||
"require_authenticated",
|
||||
"auth_required",
|
||||
];
|
||||
|
||||
/// Spring (`@PreAuthorize`, `@Secured`, …).
|
||||
pub const SPRING_ANNOTATIONS: &[&str] = &[
|
||||
"PreAuthorize",
|
||||
"PostAuthorize",
|
||||
"Secured",
|
||||
"RolesAllowed",
|
||||
"AuthenticationPrincipal",
|
||||
];
|
||||
|
||||
/// Java Servlet / JAX-RS (`@RolesAllowed`, `@RequiresAuthentication`, …).
|
||||
pub const SERVLET_ANNOTATIONS: &[&str] = &[
|
||||
"RolesAllowed",
|
||||
"DenyAll",
|
||||
"RequiresAuthentication",
|
||||
"RequiresUser",
|
||||
];
|
||||
|
||||
/// Quarkus (`@Authenticated`, `@RolesAllowed`, …).
|
||||
pub const QUARKUS_ANNOTATIONS: &[&str] = &[
|
||||
"Authenticated",
|
||||
"RolesAllowed",
|
||||
"DenyAll",
|
||||
"RequiresAuthentication",
|
||||
];
|
||||
|
||||
/// Express middleware (`app.use(requireAuth)`, `passport.authenticate`, …).
|
||||
pub const EXPRESS_MIDDLEWARES: &[&str] = &[
|
||||
"requireAuth",
|
||||
"requireUser",
|
||||
"isAuthenticated",
|
||||
"ensureAuthenticated",
|
||||
"ensureLoggedIn",
|
||||
"authenticate",
|
||||
"authMiddleware",
|
||||
"verifyToken",
|
||||
"verifyJwt",
|
||||
"checkJwt",
|
||||
"passport",
|
||||
"jwt",
|
||||
];
|
||||
|
||||
/// Koa middleware.
|
||||
pub const KOA_MIDDLEWARES: &[&str] = &[
|
||||
"requireAuth",
|
||||
"requireUser",
|
||||
"isAuthenticated",
|
||||
"ensureAuthenticated",
|
||||
"authenticate",
|
||||
"authMiddleware",
|
||||
"verifyToken",
|
||||
"verifyJwt",
|
||||
"checkJwt",
|
||||
"passport",
|
||||
"jwt",
|
||||
"koaJwt",
|
||||
];
|
||||
|
||||
/// Gin middleware (`router.Use(AuthRequired())`, `jwt.JWT()`, …).
|
||||
pub const GIN_MIDDLEWARES: &[&str] = &[
|
||||
"AuthRequired",
|
||||
"JWT",
|
||||
"JWTAuth",
|
||||
"Auth",
|
||||
"RequireAuth",
|
||||
"RequireUser",
|
||||
"VerifyToken",
|
||||
"BasicAuth",
|
||||
];
|
||||
|
||||
/// actix-web extractors (`Identity`, `BearerAuth`, …).
|
||||
pub const ACTIX_EXTRACTORS: &[&str] = &[
|
||||
"Identity",
|
||||
"BearerAuth",
|
||||
"BasicAuth",
|
||||
"JwtClaims",
|
||||
"Authenticated",
|
||||
"User",
|
||||
];
|
||||
|
||||
/// axum extractors (`Extension<User>`, `BearerAuth`, …).
|
||||
pub const AXUM_EXTRACTORS: &[&str] = &[
|
||||
"Extension<User",
|
||||
"BearerAuth",
|
||||
"RequireAuth",
|
||||
"AuthenticatedUser",
|
||||
"JwtClaims",
|
||||
];
|
||||
|
||||
/// Per-framework marker list. Returns the empty slice when the
|
||||
/// framework is not registered yet.
|
||||
pub fn markers_for(framework: AuthFramework) -> &'static [&'static str] {
|
||||
match framework {
|
||||
AuthFramework::Flask => FLASK_DECORATORS,
|
||||
AuthFramework::FastApi => FASTAPI_DECORATORS,
|
||||
AuthFramework::Django => DJANGO_DECORATORS,
|
||||
AuthFramework::Spring => SPRING_ANNOTATIONS,
|
||||
AuthFramework::JavaServlet => SERVLET_ANNOTATIONS,
|
||||
AuthFramework::Quarkus => QUARKUS_ANNOTATIONS,
|
||||
AuthFramework::Express => EXPRESS_MIDDLEWARES,
|
||||
AuthFramework::Koa => KOA_MIDDLEWARES,
|
||||
AuthFramework::Gin => GIN_MIDDLEWARES,
|
||||
AuthFramework::ActixWeb => ACTIX_EXTRACTORS,
|
||||
AuthFramework::Axum => AXUM_EXTRACTORS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive whole-string match against the per-framework list.
|
||||
pub fn is_router_auth_marker(framework: AuthFramework, marker: &str) -> bool {
|
||||
let m = marker.trim();
|
||||
markers_for(framework)
|
||||
.iter()
|
||||
.any(|cand| cand.eq_ignore_ascii_case(m))
|
||||
}
|
||||
|
||||
/// Loose match against every framework's list. Used when the call
|
||||
/// site has the language but not the specific framework — e.g. an
|
||||
/// auth-analyser folding "is this a known router-level guard?" into a
|
||||
/// per-language ruleset where the framework split is opaque.
|
||||
pub fn is_known_router_auth_marker(marker: &str) -> bool {
|
||||
let m = marker.trim();
|
||||
[
|
||||
FLASK_DECORATORS,
|
||||
FASTAPI_DECORATORS,
|
||||
DJANGO_DECORATORS,
|
||||
SPRING_ANNOTATIONS,
|
||||
SERVLET_ANNOTATIONS,
|
||||
QUARKUS_ANNOTATIONS,
|
||||
EXPRESS_MIDDLEWARES,
|
||||
KOA_MIDDLEWARES,
|
||||
GIN_MIDDLEWARES,
|
||||
ACTIX_EXTRACTORS,
|
||||
AXUM_EXTRACTORS,
|
||||
]
|
||||
.iter()
|
||||
.any(|list| list.iter().any(|cand| cand.eq_ignore_ascii_case(m)))
|
||||
}
|
||||
|
||||
/// Every router-auth marker the canonical registry knows for `lang`.
|
||||
/// Used by `auth_analysis::config::default_for` to seed
|
||||
/// `login_guard_names` so a marker added here propagates into the
|
||||
/// per-language guard list without a second edit.
|
||||
pub fn router_auth_markers_for_lang(lang: Lang) -> Vec<&'static str> {
|
||||
let lists: &[&[&str]] = match lang {
|
||||
Lang::Python => &[FLASK_DECORATORS, FASTAPI_DECORATORS, DJANGO_DECORATORS],
|
||||
Lang::Java => &[SPRING_ANNOTATIONS, SERVLET_ANNOTATIONS, QUARKUS_ANNOTATIONS],
|
||||
Lang::JavaScript | Lang::TypeScript => &[EXPRESS_MIDDLEWARES, KOA_MIDDLEWARES],
|
||||
Lang::Go => &[GIN_MIDDLEWARES],
|
||||
Lang::Rust => &[ACTIX_EXTRACTORS, AXUM_EXTRACTORS],
|
||||
_ => &[],
|
||||
};
|
||||
let mut out: Vec<&'static str> = lists.iter().flat_map(|l| l.iter().copied()).collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flask_login_required_resolves_case_insensitively() {
|
||||
assert!(is_router_auth_marker(
|
||||
AuthFramework::Flask,
|
||||
"login_required"
|
||||
));
|
||||
assert!(is_router_auth_marker(
|
||||
AuthFramework::Flask,
|
||||
"Login_Required"
|
||||
));
|
||||
assert!(!is_router_auth_marker(
|
||||
AuthFramework::Flask,
|
||||
"something_else"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_preauthorize_resolves() {
|
||||
assert!(is_router_auth_marker(AuthFramework::Spring, "PreAuthorize"));
|
||||
assert!(!is_router_auth_marker(AuthFramework::Spring, "GetMapping"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marker_matches_across_frameworks() {
|
||||
// `RolesAllowed` shows up in Spring, Servlet, and Quarkus —
|
||||
// the framework-agnostic helper finds it regardless.
|
||||
assert!(is_known_router_auth_marker("RolesAllowed"));
|
||||
assert!(is_known_router_auth_marker("login_required"));
|
||||
assert!(!is_known_router_auth_marker("not_an_auth_marker_xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_router_markers_cover_every_framework() {
|
||||
let markers = router_auth_markers_for_lang(Lang::Python);
|
||||
for &decorator in FLASK_DECORATORS {
|
||||
assert!(markers.contains(&decorator), "missing flask: {decorator}");
|
||||
}
|
||||
for &decorator in FASTAPI_DECORATORS {
|
||||
assert!(markers.contains(&decorator), "missing fastapi: {decorator}");
|
||||
}
|
||||
for &decorator in DJANGO_DECORATORS {
|
||||
assert!(markers.contains(&decorator), "missing django: {decorator}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_markers_for_unknown_lang_is_empty() {
|
||||
assert!(router_auth_markers_for_lang(Lang::Ruby).is_empty());
|
||||
assert!(router_auth_markers_for_lang(Lang::Php).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -902,6 +902,24 @@ fn is_self_scoped_session_base(base: &str) -> bool {
|
|||
| "ctx.session.currentUser"
|
||||
| "ctx.state.user"
|
||||
| "ctx.state.currentUser"
|
||||
// The caller's own id from the session is self-scoped: fetching
|
||||
// your own record with it is not IDOR (only a foreign,
|
||||
// request-supplied id is). The `.user` forms above missed the
|
||||
// `req.session.userId` / `session.uid` idiom.
|
||||
| "req.session.userId"
|
||||
| "request.session.userId"
|
||||
| "session.userId"
|
||||
| "req.session.userid"
|
||||
| "request.session.userid"
|
||||
| "session.userid"
|
||||
| "req.session.uid"
|
||||
| "request.session.uid"
|
||||
| "session.uid"
|
||||
| "ctx.session.userId"
|
||||
| "ctx.session.userid"
|
||||
| "ctx.session.uid"
|
||||
| "ctx.state.userId"
|
||||
| "ctx.state.uid"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
//! Configuration for the Rust auth-analysis pass.
|
||||
//!
|
||||
//! Holds [`AuthAnalysisRules`] (admin path/guard patterns, sink classes, and
|
||||
//! name canonicalization) that drive `rs.auth.missing_ownership_check`.
|
||||
|
||||
use crate::auth_analysis::model::SinkClass;
|
||||
use crate::labels::bare_method_name;
|
||||
use crate::utils::config::Config;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
//! Shared AST-extraction helpers for the auth-analysis framework adapters.
|
||||
//!
|
||||
//! Cross-framework primitives — analysis-unit collection, call-site and
|
||||
//! `ValueRef` extraction, and tree-sitter node/string/span helpers — used by the
|
||||
//! per-framework extractors in this directory (`express`, `axum`, `django`, …).
|
||||
|
||||
use crate::auth_analysis::config::{AuthAnalysisRules, canonical_name, matches_name, strip_quotes};
|
||||
use crate::auth_analysis::model::{
|
||||
AnalysisUnit, AnalysisUnitKind, AuthCheck, AuthCheckKind, AuthorizationModel, CallSite,
|
||||
|
|
@ -3942,6 +3948,27 @@ fn collect_param_names(
|
|||
}
|
||||
}
|
||||
}
|
||||
// TypeScript `required_parameter` / `optional_parameter`. Descend only
|
||||
// into the binding `pattern`, never the `type` annotation: the default
|
||||
// arm harvests id-like names from object-type fields (`user: { id }`)
|
||||
// and lifts typed-bounded scalar ids (`UserId: number`) into
|
||||
// `unit.params`, over-firing the user-input gate on non-route helpers.
|
||||
// Mirrors the Rust `parameter` arm plus the Go/Python id-like filter.
|
||||
"required_parameter" | "optional_parameter" => {
|
||||
if let Some(pattern) = node.child_by_field_name("pattern") {
|
||||
if pattern.kind() == "identifier" && node.child_by_field_name("type").is_some() {
|
||||
let name = text(pattern, bytes);
|
||||
if !name.is_empty()
|
||||
&& !out.contains(&name)
|
||||
&& (include_id_like_typed || !is_python_id_like_typed_param(&name))
|
||||
{
|
||||
out.push(name);
|
||||
}
|
||||
} else {
|
||||
collect_param_names(pattern, bytes, include_id_like_typed, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for idx in 0..node.named_child_count() {
|
||||
let Some(child) = node.named_child(idx as u32) else {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
//! - [`sql_semantics`]: ACL-join and `user_id`-predicate detection without a
|
||||
//! SQL parser
|
||||
|
||||
pub mod auth_markers;
|
||||
pub mod checks;
|
||||
pub mod config;
|
||||
pub mod extract;
|
||||
|
|
@ -1014,7 +1015,18 @@ fn auth_finding_to_diag(finding: &checks::AuthFinding, tree: &Tree, file_path: &
|
|||
guard_kind: None,
|
||||
message: Some(finding.message.clone()),
|
||||
labels: vec![],
|
||||
confidence: Some(Confidence::Medium),
|
||||
// Auth-analysis findings are *structural* (parameter-name + control-flow
|
||||
// shape heuristics) and carry no taint witness — `source = None`,
|
||||
// `sink_caps = 0`, no flow steps — so the per-payload dynamic oracle
|
||||
// cannot confirm or refute them (missing-authz needs a 2-user
|
||||
// differential the corpus does not run). Emitting them at Medium put a
|
||||
// large zero-witness, dynamically-Unsupported tranche on the default /
|
||||
// verified surface (the bulk of the nodegoat/railsgoat/juiceshop `auth`
|
||||
// FP flood). Demote to Low so they sit below the default min-confidence
|
||||
// and verify gates while remaining available for access-control audits.
|
||||
// assert_has tests pin rule-id presence, not confidence, so they stay
|
||||
// green.
|
||||
confidence: Some(Confidence::Low),
|
||||
evidence: Some(Evidence {
|
||||
source: None,
|
||||
sink: Some(SpanEvidence {
|
||||
|
|
@ -1037,6 +1049,7 @@ fn auth_finding_to_diag(finding: &checks::AuthFinding, tree: &Tree, file_path: &
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
619
src/baseline.rs
Normal file
619
src/baseline.rs
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
//! Baseline diffing for patch-validation CI mode (§M6.5 / Pillar A §15.1).
|
||||
//!
|
||||
//! `nyx scan --baseline <file>` reads a previous scan's JSON output (or a
|
||||
//! stripped `.nyx/baseline.json`) and joins on `Diag::stable_hash`. The
|
||||
//! result is a per-finding `VerdictDiffEntry` with a typed `Transition` that
|
||||
//! CI gates can act on.
|
||||
//!
|
||||
//! `nyx scan --baseline-write <file>` writes a stripped baseline JSON:
|
||||
//! only `stable_hash`, `dynamic_verdict`, `severity`, `path`, and `rule_id`.
|
||||
//! No source code is included.
|
||||
|
||||
use crate::commands::scan::Diag;
|
||||
use crate::evidence::VerifyStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
// Baseline entry (stripped — no source code)
|
||||
|
||||
/// A stripped baseline entry: only what is needed for cross-commit diffing.
|
||||
/// Contains no source code snippets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BaselineEntry {
|
||||
pub stable_hash: u64,
|
||||
/// Dynamic verdict status from the scan that wrote this baseline.
|
||||
/// `None` when `--verify` was not run.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dynamic_verdict: Option<VerifyStatus>,
|
||||
pub severity: String,
|
||||
pub path: String,
|
||||
pub rule_id: String,
|
||||
}
|
||||
|
||||
// Transition enum
|
||||
|
||||
/// How a finding's verdict changed between the baseline scan and the current
|
||||
/// scan.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Transition {
|
||||
/// Finding exists in the current scan but was absent from the baseline.
|
||||
New,
|
||||
/// Finding appears in both scans; verdict is unchanged (or neither scan
|
||||
/// ran `--verify`).
|
||||
Unchanged,
|
||||
/// Finding was present in the baseline but disappeared from the current
|
||||
/// scan — the vulnerability is gone.
|
||||
Resolved,
|
||||
/// Finding in both; was `NotConfirmed` in baseline, now `Confirmed`.
|
||||
Regressed,
|
||||
/// Finding in both; baseline had no verdict (or `Inconclusive` /
|
||||
/// `Unsupported`) and it is now `Confirmed`.
|
||||
FlippedConfirmed,
|
||||
/// Finding in both; was `Confirmed` in baseline, now `NotConfirmed` —
|
||||
/// the fix is proven.
|
||||
FlippedNotConfirmed,
|
||||
}
|
||||
|
||||
// VerdictDiffEntry
|
||||
|
||||
/// Per-finding verdict diff produced by comparing a baseline to a current scan.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerdictDiffEntry {
|
||||
/// Stable cross-commit identity hash.
|
||||
pub stable_hash: u64,
|
||||
pub path: String,
|
||||
pub line: usize,
|
||||
pub rule_id: String,
|
||||
/// Verdict in the baseline scan (`None` when verify was not run).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub baseline_status: Option<VerifyStatus>,
|
||||
/// Verdict in the current scan (`None` when verify was not run or finding
|
||||
/// is absent from the current scan).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_status: Option<VerifyStatus>,
|
||||
pub transition: Transition,
|
||||
}
|
||||
|
||||
/// Full verdict diff between a baseline and a current scan.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerdictDiff {
|
||||
pub entries: Vec<VerdictDiffEntry>,
|
||||
}
|
||||
|
||||
// Load / write helpers
|
||||
|
||||
/// Load baseline entries from a file.
|
||||
///
|
||||
/// Accepts two JSON formats:
|
||||
/// - Stripped baseline (`Vec<BaselineEntry>`) — written by `--baseline-write`.
|
||||
/// - Full scan output (`Vec<Diag>`) — written by `nyx scan --format json`.
|
||||
///
|
||||
/// Detection heuristic: try `Vec<BaselineEntry>` first (requires `rule_id`);
|
||||
/// fall back to `Vec<Diag>`.
|
||||
pub fn load_baseline(path: &Path) -> crate::errors::NyxResult<Vec<BaselineEntry>> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| {
|
||||
crate::errors::NyxError::Msg(format!("cannot read baseline {}: {e}", path.display()))
|
||||
})?;
|
||||
|
||||
// Try stripped format first.
|
||||
if let Ok(entries) = serde_json::from_str::<Vec<BaselineEntry>>(&content) {
|
||||
return Ok(entries);
|
||||
}
|
||||
|
||||
// Fall back to full Diag list.
|
||||
let diags: Vec<Diag> = serde_json::from_str(&content).map_err(|e| {
|
||||
crate::errors::NyxError::Msg(format!(
|
||||
"baseline {}: not a valid BaselineEntry list or Diag list: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
Ok(diags_to_baseline_entries(&diags))
|
||||
}
|
||||
|
||||
/// Convert `Diag` values to `BaselineEntry` values.
|
||||
///
|
||||
/// Only findings with a non-zero `stable_hash` are included; findings without
|
||||
/// a hash cannot be joined across scans.
|
||||
pub fn diags_to_baseline_entries(diags: &[Diag]) -> Vec<BaselineEntry> {
|
||||
diags
|
||||
.iter()
|
||||
.filter(|d| d.stable_hash != 0)
|
||||
.map(|d| BaselineEntry {
|
||||
stable_hash: d.stable_hash,
|
||||
dynamic_verdict: d
|
||||
.evidence
|
||||
.as_ref()
|
||||
.and_then(|ev| ev.dynamic_verdict.as_ref())
|
||||
.map(|vr| vr.status),
|
||||
severity: d.severity.as_db_str().to_string(),
|
||||
path: d.path.clone(),
|
||||
rule_id: d.id.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write a stripped baseline JSON to `path`.
|
||||
///
|
||||
/// The file contains only `stable_hash`, `dynamic_verdict`, `severity`,
|
||||
/// `path`, and `rule_id` — no source code snippets or flow steps.
|
||||
pub fn write_baseline(path: &Path, diags: &[Diag]) -> crate::errors::NyxResult<()> {
|
||||
let entries = diags_to_baseline_entries(diags);
|
||||
let json = serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| crate::errors::NyxError::Msg(format!("baseline serialize error: {e}")))?;
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
crate::errors::NyxError::Msg(format!(
|
||||
"cannot create baseline dir {}: {e}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
std::fs::write(path, json).map_err(|e| {
|
||||
crate::errors::NyxError::Msg(format!("cannot write baseline {}: {e}", path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
// Diff computation
|
||||
|
||||
fn classify_transition(
|
||||
baseline: Option<VerifyStatus>,
|
||||
current: Option<VerifyStatus>,
|
||||
) -> Transition {
|
||||
match (baseline, current) {
|
||||
// No verdict change (including both None)
|
||||
(a, b) if a == b => Transition::Unchanged,
|
||||
// Confirmed → NotConfirmed: fix proven
|
||||
(Some(VerifyStatus::Confirmed), Some(VerifyStatus::NotConfirmed)) => {
|
||||
Transition::FlippedNotConfirmed
|
||||
}
|
||||
// NotConfirmed → Confirmed: regression
|
||||
(Some(VerifyStatus::NotConfirmed), Some(VerifyStatus::Confirmed)) => Transition::Regressed,
|
||||
// None / Inconclusive / Unsupported → Confirmed
|
||||
(_, Some(VerifyStatus::Confirmed)) => Transition::FlippedConfirmed,
|
||||
// Everything else: treat as unchanged (e.g. Confirmed → Inconclusive
|
||||
// without a clean NotConfirmed proof is not a resolution)
|
||||
_ => Transition::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a verdict diff between a loaded baseline and the current findings.
|
||||
pub fn compute_verdict_diff(baseline: &[BaselineEntry], current: &[Diag]) -> VerdictDiff {
|
||||
// Build lookup maps keyed by stable_hash.
|
||||
let baseline_map: HashMap<u64, &BaselineEntry> =
|
||||
baseline.iter().map(|e| (e.stable_hash, e)).collect();
|
||||
let current_map: HashMap<u64, &Diag> = current
|
||||
.iter()
|
||||
.filter(|d| d.stable_hash != 0)
|
||||
.map(|d| (d.stable_hash, d))
|
||||
.collect();
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
// Walk current findings.
|
||||
for (&hash, diag) in ¤t_map {
|
||||
let current_status = diag
|
||||
.evidence
|
||||
.as_ref()
|
||||
.and_then(|ev| ev.dynamic_verdict.as_ref())
|
||||
.map(|vr| vr.status);
|
||||
|
||||
if let Some(base) = baseline_map.get(&hash) {
|
||||
let transition = classify_transition(base.dynamic_verdict, current_status);
|
||||
entries.push(VerdictDiffEntry {
|
||||
stable_hash: hash,
|
||||
path: diag.path.clone(),
|
||||
line: diag.line,
|
||||
rule_id: diag.id.clone(),
|
||||
baseline_status: base.dynamic_verdict,
|
||||
current_status,
|
||||
transition,
|
||||
});
|
||||
} else {
|
||||
// Not in baseline → New.
|
||||
entries.push(VerdictDiffEntry {
|
||||
stable_hash: hash,
|
||||
path: diag.path.clone(),
|
||||
line: diag.line,
|
||||
rule_id: diag.id.clone(),
|
||||
baseline_status: None,
|
||||
current_status,
|
||||
transition: Transition::New,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Walk baseline findings absent from current → Resolved.
|
||||
for (&hash, base) in &baseline_map {
|
||||
if !current_map.contains_key(&hash) {
|
||||
entries.push(VerdictDiffEntry {
|
||||
stable_hash: hash,
|
||||
path: base.path.clone(),
|
||||
line: 0,
|
||||
rule_id: base.rule_id.clone(),
|
||||
baseline_status: base.dynamic_verdict,
|
||||
current_status: None,
|
||||
transition: Transition::Resolved,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for deterministic output: Resolved first, then New, then the rest,
|
||||
// all sub-sorted by (path, line).
|
||||
entries.sort_by(|a, b| {
|
||||
fn order(t: Transition) -> u8 {
|
||||
match t {
|
||||
Transition::Resolved => 0,
|
||||
Transition::FlippedNotConfirmed => 1,
|
||||
Transition::New => 2,
|
||||
Transition::Regressed => 3,
|
||||
Transition::FlippedConfirmed => 4,
|
||||
Transition::Unchanged => 5,
|
||||
}
|
||||
}
|
||||
order(a.transition)
|
||||
.cmp(&order(b.transition))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
.then_with(|| a.line.cmp(&b.line))
|
||||
});
|
||||
|
||||
VerdictDiff { entries }
|
||||
}
|
||||
|
||||
// CI gates
|
||||
|
||||
/// Gate: exit code 2 if any new `Confirmed` finding appears.
|
||||
///
|
||||
/// Triggers on `transition == New && current_status == Confirmed` or
|
||||
/// `transition == FlippedConfirmed`.
|
||||
pub const GATE_NO_NEW_CONFIRMED: &str = "no-new-confirmed";
|
||||
|
||||
/// Gate: exit code 2 if any baseline-`Confirmed` finding is not fully resolved.
|
||||
///
|
||||
/// A baseline-Confirmed finding is resolved only when it is absent from the
|
||||
/// current scan (`Resolved`) or its current verdict is `NotConfirmed`
|
||||
/// (`FlippedNotConfirmed`). All other current statuses (`Confirmed`,
|
||||
/// `Inconclusive`, `Unsupported`) violate this gate.
|
||||
pub const GATE_RESOLVE_ALL_CONFIRMED: &str = "resolve-all-confirmed";
|
||||
|
||||
/// Check a named CI gate against a verdict diff.
|
||||
///
|
||||
/// Returns `true` when the gate passes (condition not violated) and `false`
|
||||
/// when it fails (caller should exit with code 2).
|
||||
///
|
||||
/// Unknown gate names always pass so future gate additions are forward-
|
||||
/// compatible without requiring a binary upgrade.
|
||||
pub fn check_gate(diff: &VerdictDiff, gate: &str) -> bool {
|
||||
match gate {
|
||||
GATE_NO_NEW_CONFIRMED => !diff.entries.iter().any(|e| {
|
||||
matches!(e.transition, Transition::New | Transition::FlippedConfirmed)
|
||||
&& e.current_status == Some(VerifyStatus::Confirmed)
|
||||
}),
|
||||
GATE_RESOLVE_ALL_CONFIRMED => !diff.entries.iter().any(|e| {
|
||||
e.baseline_status == Some(VerifyStatus::Confirmed)
|
||||
&& matches!(
|
||||
e.current_status,
|
||||
Some(VerifyStatus::Confirmed)
|
||||
// PartiallyConfirmed = sink still reachable at
|
||||
// runtime, so a baseline-Confirmed finding that is
|
||||
// now partial has NOT been resolved.
|
||||
| Some(VerifyStatus::PartiallyConfirmed)
|
||||
| Some(VerifyStatus::Inconclusive)
|
||||
| Some(VerifyStatus::Unsupported)
|
||||
)
|
||||
}),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
// Console / JSON rendering
|
||||
|
||||
fn status_str(s: Option<VerifyStatus>) -> &'static str {
|
||||
match s {
|
||||
Some(VerifyStatus::Confirmed) => "Confirmed",
|
||||
Some(VerifyStatus::PartiallyConfirmed) => "PartiallyConfirmed",
|
||||
Some(VerifyStatus::NotConfirmed) => "NotConfirmed",
|
||||
Some(VerifyStatus::Inconclusive) => "Inconclusive",
|
||||
Some(VerifyStatus::Unsupported) => "Unsupported",
|
||||
None => "(no verdict)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a verdict diff as a human-readable console summary.
|
||||
pub fn format_diff_console(diff: &VerdictDiff) -> String {
|
||||
if diff.entries.is_empty() {
|
||||
return String::from(" (no findings in baseline or current scan)\n");
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut non_unchanged = 0usize;
|
||||
|
||||
for e in &diff.entries {
|
||||
let hash_str = format!("{:016x}", e.stable_hash);
|
||||
let loc = if e.line > 0 {
|
||||
format!("{}:{}", e.path, e.line)
|
||||
} else {
|
||||
e.path.clone()
|
||||
};
|
||||
match e.transition {
|
||||
Transition::New => {
|
||||
non_unchanged += 1;
|
||||
lines.push(format!(
|
||||
" + {hash_str}: new {} at {loc}",
|
||||
status_str(e.current_status)
|
||||
));
|
||||
}
|
||||
Transition::Resolved => {
|
||||
non_unchanged += 1;
|
||||
lines.push(format!(
|
||||
" - {hash_str}: {} \u{2192} removed (resolved) at {loc}",
|
||||
status_str(e.baseline_status)
|
||||
));
|
||||
}
|
||||
Transition::FlippedNotConfirmed => {
|
||||
non_unchanged += 1;
|
||||
lines.push(format!(
|
||||
" - {hash_str}: Confirmed \u{2192} NotConfirmed at {loc} (resolved)"
|
||||
));
|
||||
}
|
||||
Transition::Regressed => {
|
||||
non_unchanged += 1;
|
||||
lines.push(format!(
|
||||
" ! {hash_str}: NotConfirmed \u{2192} Confirmed at {loc} (regressed)"
|
||||
));
|
||||
}
|
||||
Transition::FlippedConfirmed => {
|
||||
non_unchanged += 1;
|
||||
lines.push(format!(" + {hash_str}: new Confirmed at {loc}"));
|
||||
}
|
||||
Transition::Unchanged => {}
|
||||
}
|
||||
}
|
||||
|
||||
if non_unchanged == 0 {
|
||||
return String::from(" (no changes from baseline)\n");
|
||||
}
|
||||
|
||||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::scan::{Diag, compute_stable_hash};
|
||||
use crate::evidence::{Evidence, VerifyResult, VerifyStatus};
|
||||
use crate::patterns::{FindingCategory, Severity};
|
||||
|
||||
fn make_diag(path: &str, line: usize, rule: &str) -> Diag {
|
||||
let mut d = Diag {
|
||||
path: path.to_string(),
|
||||
line,
|
||||
col: 0,
|
||||
severity: Severity::High,
|
||||
id: rule.to_string(),
|
||||
category: FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: vec![],
|
||||
stable_hash: 0,
|
||||
};
|
||||
d.stable_hash = compute_stable_hash(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn with_verdict(mut d: Diag, status: VerifyStatus) -> Diag {
|
||||
d.evidence = Some(Evidence {
|
||||
dynamic_verdict: Some(VerifyResult {
|
||||
finding_id: format!("{:016x}", d.stable_hash),
|
||||
status,
|
||||
triggered_payload: None,
|
||||
reason: None,
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_finding_no_verdict() {
|
||||
let current = vec![make_diag("src/a.py", 1, "py.sqli")];
|
||||
let diff = compute_verdict_diff(&[], ¤t);
|
||||
assert_eq!(diff.entries.len(), 1);
|
||||
assert_eq!(diff.entries[0].transition, Transition::New);
|
||||
assert_eq!(diff.entries[0].current_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_confirmed_finding() {
|
||||
let current = vec![with_verdict(
|
||||
make_diag("src/a.py", 1, "py.sqli"),
|
||||
VerifyStatus::Confirmed,
|
||||
)];
|
||||
let diff = compute_verdict_diff(&[], ¤t);
|
||||
assert_eq!(diff.entries[0].transition, Transition::New);
|
||||
assert_eq!(
|
||||
diff.entries[0].current_status,
|
||||
Some(VerifyStatus::Confirmed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_finding() {
|
||||
let baseline_diag = make_diag("src/a.py", 1, "py.sqli");
|
||||
let baseline = diags_to_baseline_entries(&[baseline_diag]);
|
||||
let diff = compute_verdict_diff(&baseline, &[]);
|
||||
assert_eq!(diff.entries.len(), 1);
|
||||
assert_eq!(diff.entries[0].transition, Transition::Resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_not_confirmed() {
|
||||
let d = make_diag("src/a.py", 1, "py.sqli");
|
||||
let baseline = vec![BaselineEntry {
|
||||
stable_hash: d.stable_hash,
|
||||
dynamic_verdict: Some(VerifyStatus::Confirmed),
|
||||
severity: "high".to_string(),
|
||||
path: d.path.clone(),
|
||||
rule_id: d.id.clone(),
|
||||
}];
|
||||
let current = vec![with_verdict(d, VerifyStatus::NotConfirmed)];
|
||||
let diff = compute_verdict_diff(&baseline, ¤t);
|
||||
assert_eq!(diff.entries[0].transition, Transition::FlippedNotConfirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regressed() {
|
||||
let d = make_diag("src/a.py", 1, "py.sqli");
|
||||
let baseline = vec![BaselineEntry {
|
||||
stable_hash: d.stable_hash,
|
||||
dynamic_verdict: Some(VerifyStatus::NotConfirmed),
|
||||
severity: "high".to_string(),
|
||||
path: d.path.clone(),
|
||||
rule_id: d.id.clone(),
|
||||
}];
|
||||
let current = vec![with_verdict(d, VerifyStatus::Confirmed)];
|
||||
let diff = compute_verdict_diff(&baseline, ¤t);
|
||||
assert_eq!(diff.entries[0].transition, Transition::Regressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_no_new_confirmed_passes_when_no_confirmed() {
|
||||
let d = make_diag("src/a.py", 1, "py.sqli");
|
||||
let diff = compute_verdict_diff(&[], &[d]);
|
||||
assert!(check_gate(&diff, GATE_NO_NEW_CONFIRMED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_no_new_confirmed_fails_on_new_confirmed() {
|
||||
let current = vec![with_verdict(
|
||||
make_diag("src/a.py", 1, "py.sqli"),
|
||||
VerifyStatus::Confirmed,
|
||||
)];
|
||||
let diff = compute_verdict_diff(&[], ¤t);
|
||||
assert!(!check_gate(&diff, GATE_NO_NEW_CONFIRMED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_resolve_all_confirmed_passes_when_flipped() {
|
||||
let d = make_diag("src/a.py", 1, "py.sqli");
|
||||
let baseline = vec![BaselineEntry {
|
||||
stable_hash: d.stable_hash,
|
||||
dynamic_verdict: Some(VerifyStatus::Confirmed),
|
||||
severity: "high".to_string(),
|
||||
path: d.path.clone(),
|
||||
rule_id: d.id.clone(),
|
||||
}];
|
||||
let current = vec![with_verdict(d, VerifyStatus::NotConfirmed)];
|
||||
let diff = compute_verdict_diff(&baseline, ¤t);
|
||||
assert!(check_gate(&diff, GATE_RESOLVE_ALL_CONFIRMED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_resolve_all_confirmed_fails_when_still_confirmed() {
|
||||
let d = make_diag("src/a.py", 1, "py.sqli");
|
||||
let baseline = vec![BaselineEntry {
|
||||
stable_hash: d.stable_hash,
|
||||
dynamic_verdict: Some(VerifyStatus::Confirmed),
|
||||
severity: "high".to_string(),
|
||||
path: d.path.clone(),
|
||||
rule_id: d.id.clone(),
|
||||
}];
|
||||
let current = vec![with_verdict(d, VerifyStatus::Confirmed)];
|
||||
let diff = compute_verdict_diff(&baseline, ¤t);
|
||||
assert!(!check_gate(&diff, GATE_RESOLVE_ALL_CONFIRMED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_resolve_all_confirmed_passes_when_resolved() {
|
||||
let d = make_diag("src/a.py", 1, "py.sqli");
|
||||
let baseline = vec![BaselineEntry {
|
||||
stable_hash: d.stable_hash,
|
||||
dynamic_verdict: Some(VerifyStatus::Confirmed),
|
||||
severity: "high".to_string(),
|
||||
path: d.path.clone(),
|
||||
rule_id: d.id.clone(),
|
||||
}];
|
||||
// No current findings (finding disappeared entirely).
|
||||
let diff = compute_verdict_diff(&baseline, &[]);
|
||||
assert!(check_gate(&diff, GATE_RESOLVE_ALL_CONFIRMED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_load_roundtrip() {
|
||||
let d = with_verdict(make_diag("src/a.py", 1, "py.sqli"), VerifyStatus::Confirmed);
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
write_baseline(tmp.path(), std::slice::from_ref(&d)).unwrap();
|
||||
let loaded = load_baseline(tmp.path()).unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].stable_hash, d.stable_hash);
|
||||
assert_eq!(loaded[0].dynamic_verdict, Some(VerifyStatus::Confirmed));
|
||||
assert_eq!(loaded[0].path, "src/a.py");
|
||||
assert_eq!(loaded[0].rule_id, "py.sqli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_full_diag_json() {
|
||||
let d = with_verdict(make_diag("src/a.py", 1, "py.sqli"), VerifyStatus::Confirmed);
|
||||
let json = serde_json::to_string(&[&d]).unwrap();
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(tmp.path(), &json).unwrap();
|
||||
let loaded = load_baseline(tmp.path()).unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].stable_hash, d.stable_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_write_no_source() {
|
||||
let mut d = with_verdict(make_diag("src/a.py", 1, "py.sqli"), VerifyStatus::Confirmed);
|
||||
// Add a flow_step with a snippet (source code) to the evidence.
|
||||
if let Some(ref mut ev) = d.evidence {
|
||||
ev.flow_steps = vec![crate::evidence::FlowStep {
|
||||
step: 1,
|
||||
kind: crate::evidence::FlowStepKind::Source,
|
||||
file: "src/a.py".into(),
|
||||
line: 1,
|
||||
col: 0,
|
||||
snippet: Some("SECRET CODE".into()),
|
||||
variable: None,
|
||||
callee: None,
|
||||
function: None,
|
||||
is_cross_file: false,
|
||||
}];
|
||||
}
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
write_baseline(tmp.path(), &[d]).unwrap();
|
||||
let content = std::fs::read_to_string(tmp.path()).unwrap();
|
||||
assert!(
|
||||
!content.contains("SECRET CODE"),
|
||||
"baseline must not contain source code"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_gate_passes() {
|
||||
let diff = VerdictDiff { entries: vec![] };
|
||||
assert!(check_gate(&diff, "some-future-gate-name"));
|
||||
}
|
||||
}
|
||||
327
src/callgraph.rs
327
src/callgraph.rs
|
|
@ -20,16 +20,13 @@ use smallvec::SmallVec;
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Metadata attached to each call-graph edge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CallEdge {
|
||||
/// The raw callee string as it appeared in source (e.g. `"env::var"`).
|
||||
/// Preserved for diagnostics, **not** the normalized form used for resolution.
|
||||
#[allow(dead_code)] // used for future diagnostics and path display
|
||||
pub call_site: String,
|
||||
}
|
||||
|
||||
|
|
@ -52,10 +49,10 @@ pub struct AmbiguousCallee {
|
|||
///
|
||||
/// Nodes are [`FuncKey`]s (one per function definition across all files).
|
||||
/// Edges represent call-site relationships resolved after pass 1.
|
||||
#[derive(Debug)]
|
||||
pub struct CallGraph {
|
||||
pub graph: DiGraph<FuncKey, CallEdge>,
|
||||
/// `FuncKey → NodeIndex` for quick lookup.
|
||||
#[allow(dead_code)] // used for future topo-ordered analysis and call-graph queries
|
||||
pub index: HashMap<FuncKey, NodeIndex>,
|
||||
/// Callee strings that could not be resolved to any [`FuncKey`].
|
||||
pub unresolved_not_found: Vec<UnresolvedCallee>,
|
||||
|
|
@ -77,9 +74,7 @@ pub struct CallGraphAnalysis {
|
|||
pub topo_scc_callee_first: Vec<usize>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Callee-name normalization
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the last segment of a qualified callee name for resolution.
|
||||
///
|
||||
|
|
@ -165,9 +160,7 @@ pub(crate) fn callee_container_hint(raw: &str) -> &str {
|
|||
""
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Class / container → method index
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-language `(container, method_name)` → candidate [`FuncKey`] index.
|
||||
///
|
||||
|
|
@ -260,20 +253,6 @@ impl ClassMethodIndex {
|
|||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of distinct `(lang, container, method)` keys. Exposed
|
||||
/// for diagnostics / tests; production code uses [`Self::resolve`].
|
||||
#[allow(dead_code)]
|
||||
pub fn container_keys_len(&self) -> usize {
|
||||
self.by_container.len()
|
||||
}
|
||||
|
||||
/// Number of distinct `(lang, method)` keys. Exposed for
|
||||
/// diagnostics / tests.
|
||||
#[allow(dead_code)]
|
||||
pub fn name_keys_len(&self) -> usize {
|
||||
self.by_name.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type hierarchy index ────────────────────────────────────────────────
|
||||
|
|
@ -293,11 +272,6 @@ impl ClassMethodIndex {
|
|||
pub struct TypeHierarchyIndex {
|
||||
/// `(lang, super_type)` → distinct sub-type / impl container names.
|
||||
by_super: HashMap<(Lang, String), SmallVec<[String; 4]>>,
|
||||
/// `(lang, sub_type)` → super-types this type extends / implements.
|
||||
/// Future use for `super.method()` resolution; populated for
|
||||
/// completeness today.
|
||||
#[allow(dead_code)]
|
||||
by_sub: HashMap<(Lang, String), SmallVec<[String; 2]>>,
|
||||
}
|
||||
|
||||
impl TypeHierarchyIndex {
|
||||
|
|
@ -308,7 +282,6 @@ impl TypeHierarchyIndex {
|
|||
/// summary) collapse via the membership check.
|
||||
pub fn build(summaries: &GlobalSummaries) -> Self {
|
||||
let mut by_super: HashMap<(Lang, String), SmallVec<[String; 4]>> = HashMap::new();
|
||||
let mut by_sub: HashMap<(Lang, String), SmallVec<[String; 2]>> = HashMap::new();
|
||||
|
||||
for (key, summary) in summaries.iter() {
|
||||
let lang = key.lang;
|
||||
|
|
@ -320,14 +293,10 @@ impl TypeHierarchyIndex {
|
|||
if !subs.iter().any(|s| s == sub) {
|
||||
subs.push(sub.clone());
|
||||
}
|
||||
let sups = by_sub.entry((lang, sub.clone())).or_default();
|
||||
if !sups.iter().any(|s| s == sup) {
|
||||
sups.push(sup.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TypeHierarchyIndex { by_super, by_sub }
|
||||
TypeHierarchyIndex { by_super }
|
||||
}
|
||||
|
||||
/// Return the distinct sub-type / impl container names for
|
||||
|
|
@ -341,16 +310,6 @@ impl TypeHierarchyIndex {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Return the recorded super-types of `sub_type`. Empty when
|
||||
/// `sub_type` has no recorded super-types in this language.
|
||||
#[allow(dead_code)]
|
||||
pub fn supers_of(&self, lang: Lang, sub_type: &str) -> &[String] {
|
||||
self.by_sub
|
||||
.get(&(lang, sub_type.to_string()))
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Number of distinct `(lang, super_type)` keys. Exposed for
|
||||
/// diagnostics / tests.
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -409,9 +368,7 @@ impl TypeHierarchyIndex {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Call-graph construction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the whole-program call graph from merged summaries.
|
||||
///
|
||||
|
|
@ -777,9 +734,7 @@ fn resolve_via_interop(
|
|||
None
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SCC / topological analysis
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute SCC decomposition and topological ordering of the call graph.
|
||||
///
|
||||
|
|
@ -807,9 +762,7 @@ pub fn analyse(cg: &CallGraph) -> CallGraphAnalysis {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// File-level batch ordering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A batch of files at a single topological position, annotated with whether
|
||||
/// any contributing SCC contains mutual recursion (len > 1) and whether any
|
||||
|
|
@ -862,6 +815,141 @@ pub fn callers_of(cg: &CallGraph, callee: &FuncKey) -> Vec<FuncKey> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Reverse-edge BFS: return every [`FuncKey`] that *transitively* calls
|
||||
/// `callee`, i.e. the union of [`callers_of`] applied recursively until
|
||||
/// the reverse frontier is exhausted.
|
||||
///
|
||||
/// Used by the chain composer to widen file-scoped reach: a sink inside
|
||||
/// `internal_helper.py` whose enclosing function is reached only through
|
||||
/// `routes.py` is *reachable* in the chain sense, but the file-local
|
||||
/// match in `chain::edges::locate_reach` / `chain::search::compose_chain`
|
||||
/// misses it. This helper produces the closure once so callers can
|
||||
/// resolve reach in O(1) afterwards.
|
||||
///
|
||||
/// Excludes `callee` itself from the returned set, matching the
|
||||
/// "strictly upstream" semantics callers want. Empty when `callee` is
|
||||
/// unknown to the graph.
|
||||
///
|
||||
/// Cost: O(V + E) BFS from `callee`'s reverse frontier; bounded by the
|
||||
/// connected component size.
|
||||
pub fn callers_transitive(cg: &CallGraph, callee: &FuncKey) -> std::collections::HashSet<FuncKey> {
|
||||
let mut seen: std::collections::HashSet<FuncKey> = std::collections::HashSet::new();
|
||||
let Some(&start) = cg.index.get(callee) else {
|
||||
return seen;
|
||||
};
|
||||
let mut frontier: Vec<NodeIndex> = cg
|
||||
.graph
|
||||
.neighbors_directed(start, petgraph::Direction::Incoming)
|
||||
.collect();
|
||||
while let Some(node) = frontier.pop() {
|
||||
let key = cg.graph[node].clone();
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
for next in cg
|
||||
.graph
|
||||
.neighbors_directed(node, petgraph::Direction::Incoming)
|
||||
{
|
||||
if !seen.contains(&cg.graph[next]) {
|
||||
frontier.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// File-level transitive reach map built from a [`CallGraph`].
|
||||
///
|
||||
/// For each `namespace` (file path) in the graph, records every other
|
||||
/// namespace that contains at least one transitive caller. Built once
|
||||
/// per scan so the chain composer can widen a finding's
|
||||
/// `Reach::Reachable` decision beyond the file-local heuristic in
|
||||
/// `chain::edges::locate_reach` without re-running BFS per
|
||||
/// finding.
|
||||
///
|
||||
/// Map shape: `callee_namespace → { caller_namespace, … }`. A file
|
||||
/// always appears in its own caller set so intra-file recursion stays
|
||||
/// reachable.
|
||||
///
|
||||
/// `scan_root` is optional path-normalisation context. Callers that
|
||||
/// build the map without a scan root must pass project-relative POSIX
|
||||
/// paths to [`FileReachMap::reaches`] directly. When a root is set
|
||||
/// (typical in production scans), [`FileReachMap::reaches`] applies
|
||||
/// [`crate::symbol::normalize_namespace`] to its arguments before
|
||||
/// lookup so absolute host paths (the convention on
|
||||
/// [`crate::commands::scan::Diag`]'s `path`) and project-relative paths
|
||||
/// (the convention on call-graph [`FuncKey::namespace`] and
|
||||
/// [`crate::surface::SourceLocation::file`]) both resolve to the
|
||||
/// stored keys.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct FileReachMap {
|
||||
by_callee_ns: HashMap<String, std::collections::HashSet<String>>,
|
||||
scan_root: Option<String>,
|
||||
}
|
||||
|
||||
impl FileReachMap {
|
||||
/// Build the map from every function's reverse transitive closure.
|
||||
///
|
||||
/// O(V × (V + E)) worst case, but the per-function BFS is sparse on
|
||||
/// real call graphs (median in-degree < 4 on the eval corpus).
|
||||
///
|
||||
/// The returned map has no scan root configured; pair with
|
||||
/// [`FileReachMap::with_scan_root`] when callers may pass absolute
|
||||
/// paths.
|
||||
pub fn build(cg: &CallGraph) -> Self {
|
||||
let mut by_callee_ns: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
|
||||
for callee in cg.index.keys() {
|
||||
let entry = by_callee_ns.entry(callee.namespace.clone()).or_default();
|
||||
entry.insert(callee.namespace.clone());
|
||||
for caller in callers_transitive(cg, callee) {
|
||||
entry.insert(caller.namespace);
|
||||
}
|
||||
}
|
||||
FileReachMap {
|
||||
by_callee_ns,
|
||||
scan_root: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a scan root so [`FileReachMap::reaches`] can normalise
|
||||
/// absolute host paths back to the project-relative POSIX form the
|
||||
/// map keys use. Pass `None` to clear an existing root.
|
||||
pub fn with_scan_root<P: AsRef<std::path::Path>>(mut self, root: Option<P>) -> Self {
|
||||
self.scan_root = root.map(|p| p.as_ref().to_string_lossy().into_owned());
|
||||
self
|
||||
}
|
||||
|
||||
/// True when `caller` transitively reaches at least one function
|
||||
/// defined in `callee`. Inputs may be either project-relative
|
||||
/// POSIX paths (matching the call-graph namespace convention) or
|
||||
/// absolute host paths when a scan root was set via
|
||||
/// [`FileReachMap::with_scan_root`]. False when either path is
|
||||
/// unknown to the graph (conservative: chain composer falls back
|
||||
/// to the file-local heuristic).
|
||||
pub fn reaches(&self, caller: &str, callee: &str) -> bool {
|
||||
let lookup_callee = self.normalize(callee);
|
||||
let lookup_caller = self.normalize(caller);
|
||||
self.by_callee_ns
|
||||
.get(lookup_callee.as_ref())
|
||||
.is_some_and(|set| set.contains(lookup_caller.as_ref()))
|
||||
}
|
||||
|
||||
/// Number of distinct callee namespaces tracked. Exposed for
|
||||
/// diagnostics / tests.
|
||||
pub fn callee_ns_len(&self) -> usize {
|
||||
self.by_callee_ns.len()
|
||||
}
|
||||
|
||||
fn normalize<'a>(&self, path: &'a str) -> std::borrow::Cow<'a, str> {
|
||||
match self.scan_root.as_deref() {
|
||||
Some(root) => {
|
||||
std::borrow::Cow::Owned(crate::symbol::normalize_namespace(path, Some(root)))
|
||||
}
|
||||
None => std::borrow::Cow::Borrowed(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the set of file namespaces that must be re-analysed when a
|
||||
/// given set of callee [`FuncKey`]s have had their summaries refined.
|
||||
///
|
||||
|
|
@ -905,10 +993,16 @@ pub fn scc_spans_files(cg: &CallGraph, scc: &[NodeIndex]) -> bool {
|
|||
iter.any(|n| cg.graph[*n].namespace.as_str() != first_ns)
|
||||
}
|
||||
|
||||
/// Like [`scc_file_batches`] but annotates each batch with whether any
|
||||
/// contributing SCC has mutual recursion (`len > 1`).
|
||||
/// Map SCC topological order to an ordered sequence of file-path batches
|
||||
/// annotated with whether any contributing SCC is mutually recursive
|
||||
/// (`len > 1`) or cross-file.
|
||||
///
|
||||
/// Returns `(ordered_batches, orphan_files)`.
|
||||
/// A file is placed in the earliest batch where any of its functions appear
|
||||
/// (min topo index), so leaf callees become available before the callers
|
||||
/// that depend on them.
|
||||
///
|
||||
/// Returns `(ordered_batches, orphan_files)`. Orphans are paths from
|
||||
/// `all_files` that have no functions in the call graph.
|
||||
pub fn scc_file_batches_with_metadata<'a>(
|
||||
cg: &CallGraph,
|
||||
analysis: &CallGraphAnalysis,
|
||||
|
|
@ -989,8 +1083,8 @@ pub fn scc_file_batches_with_metadata<'a>(
|
|||
///
|
||||
/// Returns `(ordered_batches, orphan_files)` where orphan_files are paths
|
||||
/// from `all_files` that have no functions in the call graph.
|
||||
#[allow(dead_code)] // kept for tests; production callers use scc_file_batches_with_metadata
|
||||
pub fn scc_file_batches<'a>(
|
||||
#[cfg(test)]
|
||||
pub(super) fn scc_file_batches<'a>(
|
||||
cg: &CallGraph,
|
||||
analysis: &CallGraphAnalysis,
|
||||
all_files: &'a [PathBuf],
|
||||
|
|
@ -1033,9 +1127,7 @@ pub fn scc_file_batches<'a>(
|
|||
(batches, orphans)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -2798,4 +2890,127 @@ mod tests {
|
|||
assert!(cg.unresolved_not_found.is_empty());
|
||||
assert!(cg.unresolved_ambiguous.is_empty());
|
||||
}
|
||||
|
||||
// ── callers_transitive + FileReachMap ───────────────────────────────
|
||||
|
||||
/// Three-hop chain across three files:
|
||||
/// `routes.py::handle -> service.py::process -> helper.py::sink`
|
||||
/// `callers_transitive(sink)` must return both `process` and `handle`.
|
||||
/// `FileReachMap` must record `routes.py` and `service.py` as callers
|
||||
/// of `helper.py`.
|
||||
#[test]
|
||||
fn callers_transitive_walks_multi_hop_chain() {
|
||||
let handle = make_summary("handle", "routes.py", "python", 0, vec!["process"]);
|
||||
let process = make_summary("process", "service.py", "python", 0, vec!["sink"]);
|
||||
let sink = make_summary("sink", "helper.py", "python", 0, vec![]);
|
||||
let gs = merge_summaries(vec![handle, process, sink], None);
|
||||
let cg = build_call_graph(&gs, &[]);
|
||||
|
||||
let sink_key = FuncKey {
|
||||
lang: Lang::Python,
|
||||
namespace: "helper.py".into(),
|
||||
name: "sink".into(),
|
||||
arity: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
let transitive = callers_transitive(&cg, &sink_key);
|
||||
let caller_names: std::collections::HashSet<String> =
|
||||
transitive.iter().map(|k| k.name.clone()).collect();
|
||||
assert!(
|
||||
caller_names.contains("process"),
|
||||
"process should reach sink"
|
||||
);
|
||||
assert!(caller_names.contains("handle"), "handle should reach sink");
|
||||
assert_eq!(transitive.len(), 2, "sink itself must be excluded");
|
||||
|
||||
let reach = FileReachMap::build(&cg);
|
||||
assert!(reach.reaches("routes.py", "helper.py"));
|
||||
assert!(reach.reaches("service.py", "helper.py"));
|
||||
assert!(reach.reaches("helper.py", "helper.py"), "self-reach");
|
||||
assert!(!reach.reaches("helper.py", "routes.py"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callers_transitive_empty_for_unknown_key() {
|
||||
let leaf = make_summary("leaf", "a.py", "python", 0, vec![]);
|
||||
let gs = merge_summaries(vec![leaf], None);
|
||||
let cg = build_call_graph(&gs, &[]);
|
||||
let ghost = FuncKey {
|
||||
lang: Lang::Python,
|
||||
namespace: "nowhere.py".into(),
|
||||
name: "ghost".into(),
|
||||
arity: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(callers_transitive(&cg, &ghost).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_reach_map_handles_disconnected_components() {
|
||||
let a_caller = make_summary("a_caller", "a.py", "python", 0, vec!["a_sink"]);
|
||||
let a_sink = make_summary("a_sink", "a.py", "python", 0, vec![]);
|
||||
let b_caller = make_summary("b_caller", "b.py", "python", 0, vec!["b_sink"]);
|
||||
let b_sink = make_summary("b_sink", "b.py", "python", 0, vec![]);
|
||||
let gs = merge_summaries(vec![a_caller, a_sink, b_caller, b_sink], None);
|
||||
let cg = build_call_graph(&gs, &[]);
|
||||
let reach = FileReachMap::build(&cg);
|
||||
|
||||
assert!(reach.reaches("a.py", "a.py"));
|
||||
assert!(reach.reaches("b.py", "b.py"));
|
||||
// Disconnected: a.py does not reach b.py.
|
||||
assert!(!reach.reaches("a.py", "b.py"));
|
||||
assert!(!reach.reaches("b.py", "a.py"));
|
||||
assert_eq!(reach.callee_ns_len(), 2);
|
||||
}
|
||||
|
||||
/// `with_scan_root` normalises absolute host paths to the
|
||||
/// project-relative POSIX form the map keys carry, so
|
||||
/// `reaches("/abs/scan/routes.py", "/abs/scan/helper.py")` finds
|
||||
/// the same entry as the project-relative
|
||||
/// `reaches("routes.py", "helper.py")` call. Mirrors the
|
||||
/// production wire-up in `src/commands/scan.rs`: the call-graph
|
||||
/// uses project-relative namespaces while `Diag.path` (from
|
||||
/// `src/ast.rs`) is the absolute walker path.
|
||||
#[test]
|
||||
fn file_reach_map_with_scan_root_normalises_absolute_paths() {
|
||||
let handle = make_summary("handle", "routes.py", "python", 0, vec!["sink"]);
|
||||
let sink = make_summary("sink", "helper.py", "python", 0, vec![]);
|
||||
let gs = merge_summaries(vec![handle, sink], None);
|
||||
let cg = build_call_graph(&gs, &[]);
|
||||
let scan_root = std::path::Path::new("/abs/scan");
|
||||
let reach = FileReachMap::build(&cg).with_scan_root(Some(scan_root));
|
||||
|
||||
// Mixed conventions: surface (project-relative) caller,
|
||||
// Diag (absolute) callee. Pre-fix this returned false.
|
||||
assert!(reach.reaches("routes.py", "/abs/scan/helper.py"));
|
||||
// Both absolute: also resolves.
|
||||
assert!(reach.reaches("/abs/scan/routes.py", "/abs/scan/helper.py"));
|
||||
// Trailing-slash root works.
|
||||
let reach_trail =
|
||||
FileReachMap::build(&cg).with_scan_root(Some(std::path::Path::new("/abs/scan/")));
|
||||
assert!(reach_trail.reaches("/abs/scan/routes.py", "/abs/scan/helper.py"));
|
||||
// Both project-relative: still resolves (legacy behaviour).
|
||||
assert!(reach.reaches("routes.py", "helper.py"));
|
||||
// Path outside the root falls through normalize_namespace
|
||||
// unchanged and does not collide with a project-relative key.
|
||||
assert!(!reach.reaches("/other/root/routes.py", "/other/root/helper.py"));
|
||||
}
|
||||
|
||||
/// `with_scan_root(None)` clears a previously set root and
|
||||
/// restores strict project-relative lookup semantics.
|
||||
#[test]
|
||||
fn file_reach_map_with_scan_root_none_clears_root() {
|
||||
let handle = make_summary("handle", "routes.py", "python", 0, vec!["sink"]);
|
||||
let sink = make_summary("sink", "helper.py", "python", 0, vec![]);
|
||||
let gs = merge_summaries(vec![handle, sink], None);
|
||||
let cg = build_call_graph(&gs, &[]);
|
||||
let reach: FileReachMap = FileReachMap::build(&cg)
|
||||
.with_scan_root(Some(std::path::Path::new("/abs/scan")))
|
||||
.with_scan_root::<&std::path::Path>(None);
|
||||
|
||||
// Absolute lookup no longer resolves once root is cleared.
|
||||
assert!(!reach.reaches("/abs/scan/routes.py", "/abs/scan/helper.py"));
|
||||
// Project-relative still works.
|
||||
assert!(reach.reaches("routes.py", "helper.py"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,9 +121,7 @@ fn extract_case_literal_text<'a>(case: Node<'a>, lang: &str, code: &'a [u8]) ->
|
|||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Exception-source detection for try/catch wiring
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Returns true if this CFG node can implicitly raise an exception (calls).
|
||||
/// Explicit throws are collected separately via `throw_targets`.
|
||||
|
|
@ -190,9 +188,7 @@ pub(super) fn extract_catch_param_name<'a>(
|
|||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Ruby begin/rescue/ensure handler
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Builds CFG for Ruby's `begin`/`rescue`/`ensure` blocks (and `body_statement`
|
||||
/// with inline rescue). Ruby's `begin` has no `body` field, the try-body
|
||||
|
|
@ -442,9 +438,7 @@ pub(super) fn build_begin_rescue<'a>(
|
|||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// switch handler, multi-way dispatch with fallthrough
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// True for AST kinds that wrap a single switch case body.
|
||||
pub(super) fn is_switch_case_kind(kind: &str) -> bool {
|
||||
|
|
@ -780,9 +774,7 @@ pub(super) fn build_switch<'a>(
|
|||
exits
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// try/catch/finally handler
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build_try<'a>(
|
||||
|
|
|
|||
|
|
@ -388,9 +388,7 @@ fn js_catch_no_param_no_synthetic() {
|
|||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Ruby begin/rescue/ensure tests
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ruby_begin_rescue_has_exception_edges() {
|
||||
|
|
@ -540,9 +538,7 @@ fn ruby_multiple_rescue_clauses() {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Short-circuit evaluation tests
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Helper: collect all If nodes from the CFG.
|
||||
fn if_nodes(cfg: &Cfg) -> Vec<NodeIndex> {
|
||||
|
|
@ -2008,10 +2004,8 @@ fn local_summary_callees_have_distinct_ordinals() {
|
|||
assert_ne!(ord0, ord1, "ordinals must differ across sites");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Anonymous function body naming via syntactic context
|
||||
// (derive_anon_fn_name_from_context coverage)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn js_body_names(src: &[u8]) -> Vec<String> {
|
||||
let ts_lang = Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
|
|
@ -2531,9 +2525,7 @@ fn pointer_disabled_skips_subscript_synthesis() {
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Gap-filling: switch / for / do-while / nested loops / re-throw
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// JS `switch` should produce one synthetic dispatch `If` node per
|
||||
/// case (default excluded when at the tail), plus True edges into
|
||||
|
|
@ -2908,12 +2900,10 @@ fn js_empty_function_body_well_formed() {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Loop CFG structure: every loop variant must produce a Loop header
|
||||
// with at least one Back edge that targets that header. Without these
|
||||
// invariants the SSA loop-induction-variable phi placement is wrong
|
||||
// and the abstract-interp widening points are missed.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn loop_headers(cfg: &Cfg) -> Vec<NodeIndex> {
|
||||
cfg.node_indices()
|
||||
|
|
@ -3958,3 +3948,134 @@ fn rhs_array_literal_elements_recognise_per_language_shapes() {
|
|||
// Non-array-shape node returns empty (defensive guard).
|
||||
assert!(run("javascript", b"const x = tainted;\n", &["identifier"]).is_empty());
|
||||
}
|
||||
|
||||
/// `CalleeSite.span` should carry the 1-based (line, col) of each call's
|
||||
/// node span so downstream consumers (surface map, datastore/external
|
||||
/// detectors) can render real coordinates instead of `line: 0`.
|
||||
#[test]
|
||||
fn callee_site_span_carries_line_and_column() {
|
||||
// Three calls on three different lines. The leading newline puts
|
||||
// line 1 at the blank line; `helper(x, y);` is on line 3, etc.
|
||||
let src = b"
|
||||
function outer(obj, x, y) {
|
||||
helper(x, y);
|
||||
obj.method(x);
|
||||
}
|
||||
";
|
||||
let ts_lang = Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let file_cfg = parse_to_file_cfg(src, "javascript", ts_lang);
|
||||
let (_key, outer) = file_cfg
|
||||
.summaries
|
||||
.iter()
|
||||
.find(|(k, _)| k.name == "outer")
|
||||
.expect("outer summary should exist");
|
||||
|
||||
let helper_site = outer
|
||||
.callees
|
||||
.iter()
|
||||
.find(|c| c.name == "helper")
|
||||
.expect("helper call should be recorded");
|
||||
let (line, col) = helper_site.span.expect("span populated at CFG-build time");
|
||||
assert_eq!(line, 3, "helper(...) sits on the 3rd source line");
|
||||
assert!(col >= 5, "indented 4 spaces — column is 1-based and > 4");
|
||||
|
||||
let method_site = outer
|
||||
.callees
|
||||
.iter()
|
||||
.find(|c| c.name.ends_with("method"))
|
||||
.expect("method call should be recorded");
|
||||
let (mline, _) = method_site.span.expect("method span populated");
|
||||
assert_eq!(mline, 4, "obj.method(x) on line 4");
|
||||
}
|
||||
|
||||
// Constant-branch fold: CondArith capture + evaluation
|
||||
|
||||
/// `CondArith::eval`/`eval_bool` must fold the two OWASP-Benchmark
|
||||
/// arithmetic guard shapes to a definite boolean, using integer
|
||||
/// (truncating) division, and must return `None` — never a wrong fold —
|
||||
/// for any undefined operation or unresolved variable.
|
||||
#[test]
|
||||
fn cond_arith_eval_is_sound() {
|
||||
use crate::cfg::{BinOp, CondArith, CondVal};
|
||||
let lit = |n| Box::new(CondArith::Lit(n));
|
||||
let var = |s: &str| Box::new(CondArith::Var(s.to_string()));
|
||||
let bin = |op, l, r| Box::new(CondArith::Bin(op, l, r));
|
||||
|
||||
// num = 86 resolver.
|
||||
let r86 = |name: &str| if name == "num" { Some(86) } else { None };
|
||||
// (7*42) - num > 200 → 208 > 200 → true.
|
||||
let shape1 = CondArith::Bin(
|
||||
BinOp::Gt,
|
||||
bin(BinOp::Sub, bin(BinOp::Mul, lit(7), lit(42)), var("num")),
|
||||
lit(200),
|
||||
);
|
||||
assert_eq!(shape1.eval_bool(&r86), Some(true));
|
||||
|
||||
// (500/42) + num > 200 → 11 + 196 = 207 > 200 → true (integer div).
|
||||
let r196 = |name: &str| if name == "num" { Some(196) } else { None };
|
||||
let shape2 = CondArith::Bin(
|
||||
BinOp::Gt,
|
||||
bin(BinOp::Add, bin(BinOp::Div, lit(500), lit(42)), var("num")),
|
||||
lit(200),
|
||||
);
|
||||
assert_eq!(shape2.eval_bool(&r196), Some(true));
|
||||
// Integer division truncates toward zero (500/42 == 11, not ~11.9).
|
||||
assert_eq!(
|
||||
CondArith::Bin(BinOp::Div, lit(500), lit(42)).eval(&r86),
|
||||
Some(CondVal::Int(11))
|
||||
);
|
||||
|
||||
// Unresolved variable → None (no prune).
|
||||
let none = |_: &str| None;
|
||||
assert_eq!(shape1.eval_bool(&none), None);
|
||||
|
||||
// Division / modulo by zero → None (never a wrong fold).
|
||||
assert_eq!(CondArith::Bin(BinOp::Div, lit(1), lit(0)).eval(&r86), None);
|
||||
assert_eq!(CondArith::Bin(BinOp::Mod, lit(1), lit(0)).eval(&r86), None);
|
||||
|
||||
// Arithmetic overflow → None.
|
||||
assert_eq!(
|
||||
CondArith::Bin(BinOp::Mul, lit(i64::MAX), lit(2)).eval(&r86),
|
||||
None
|
||||
);
|
||||
|
||||
// Bare integer at the top level is not a branch condition → eval_bool None.
|
||||
assert_eq!(CondArith::Lit(1).eval_bool(&r86), None);
|
||||
|
||||
// Comparing a boolean sub-result as an integer operand → None.
|
||||
let cmp = bin(BinOp::Gt, lit(2), lit(1)); // yields Bool
|
||||
assert_eq!(CondArith::Bin(BinOp::Add, cmp, lit(1)).eval(&r86), None);
|
||||
}
|
||||
|
||||
/// The CFG builder must capture a pure integer-arithmetic comparison as a
|
||||
/// `CondArith` on the `If` node, and must refuse (None) any condition that
|
||||
/// touches a call / field access / string.
|
||||
#[test]
|
||||
fn build_cond_arith_captures_pure_int_comparison() {
|
||||
let ts_lang = Language::from(tree_sitter_java::LANGUAGE);
|
||||
let src = br#"
|
||||
class C {
|
||||
void m(int num, String s) {
|
||||
if ((7 * 42) - num > 200) { foo(); }
|
||||
if (s.length() > 200) { bar(); }
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let (cfg, _entry) = parse_and_build(src, "java", ts_lang);
|
||||
let ifs = if_nodes(&cfg);
|
||||
let arith: Vec<_> = ifs
|
||||
.iter()
|
||||
.filter_map(|&n| cfg[n].cond_arith.clone())
|
||||
.collect();
|
||||
|
||||
// Exactly one If condition is a pure int-arith comparison; the
|
||||
// `s.length() > 200` one must NOT be captured (it contains a call).
|
||||
assert_eq!(
|
||||
arith.len(),
|
||||
1,
|
||||
"only the pure int comparison should yield a CondArith, got {arith:?}"
|
||||
);
|
||||
// It folds to a definite bool once `num` is known constant.
|
||||
let r = |name: &str| if name == "num" { Some(86) } else { None };
|
||||
assert_eq!(arith[0].eval_bool(&r), Some(true));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use super::helpers::first_member_label;
|
||||
use super::{
|
||||
AstMeta, Cfg, EdgeKind, MAX_COND_VARS, MAX_CONDITION_TEXT_LEN, NodeInfo, StmtKind,
|
||||
collect_idents, connect_all, detect_eq_with_const, detect_negation, has_call_descendant,
|
||||
member_expr_text, push_node, text_of, try_lower_jsx_dangerous_html,
|
||||
build_cond_arith, collect_idents, connect_all, detect_eq_with_const, detect_negation,
|
||||
has_call_descendant, member_expr_text, push_node, text_of, try_lower_jsx_dangerous_html,
|
||||
};
|
||||
use crate::labels::{DataLabel, LangAnalysisRules, classify};
|
||||
use crate::utils::snippet::truncate_at_char_boundary;
|
||||
|
|
@ -10,9 +10,7 @@ use petgraph::graph::NodeIndex;
|
|||
use smallvec::SmallVec;
|
||||
use tree_sitter::Node;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Short-circuit boolean operator helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(super) enum BoolOp {
|
||||
|
|
@ -225,6 +223,13 @@ pub(super) fn build_ternary_diamond<'a>(
|
|||
// taint engine's equality-narrowing fires for `x === 'literal' ? …`.
|
||||
let cond_if = push_condition_node(g, cond_ast, lang, code, enclosing_func);
|
||||
g[cond_if].is_eq_with_const = detect_eq_with_const(cond_ast, lang);
|
||||
// Capture the pure int-arith + comparison tree so `fold_constant_branches`
|
||||
// can prune a dead constant-condition arm of the ternary (e.g. Java
|
||||
// `(7*18)+num > 200 ? "const" : param` with `num` a known int constant),
|
||||
// exactly as it does for the if-form. `build_cond_arith` is conservative
|
||||
// (returns None for any call/field/string/`&&`/`||`/`!` shape) so this is
|
||||
// sound for every language the diamond fires on.
|
||||
g[cond_if].cond_arith = build_cond_arith(cond_ast, lang, code, 0);
|
||||
connect_all(g, preds, cond_if, pred_edge);
|
||||
|
||||
// 2. Branches. Each branch produces its own exit frontier (≥ 1 node) ,
|
||||
|
|
|
|||
|
|
@ -90,9 +90,7 @@ fn collect_ts_type_alias_local_collections(root: Node<'_>, code: &[u8], out: &mu
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Java
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk the AST for `class_declaration` nodes whose body contains
|
||||
/// `field_declaration`s with classifiable types. Only class-level
|
||||
|
|
@ -144,9 +142,7 @@ fn collect_java(root: Node<'_>, code: &[u8], out: &mut HashMap<String, DtoFields
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// TypeScript / JavaScript
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk for `interface_declaration` and `class_declaration` nodes.
|
||||
/// Interfaces with `property_signature` children and classes with
|
||||
|
|
@ -224,9 +220,7 @@ fn extract_ts_property<'a>(node: Node<'a>, code: &'a [u8]) -> Option<(String, Ty
|
|||
Some((field_name, kind))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Rust
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk for `struct_item` nodes whose body lists named fields.
|
||||
fn collect_rust(root: Node<'_>, code: &[u8], out: &mut HashMap<String, DtoFields>) {
|
||||
|
|
@ -276,9 +270,7 @@ fn collect_rust(root: Node<'_>, code: &[u8], out: &mut HashMap<String, DtoFields
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Python (Pydantic)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk for `class_definition` nodes whose superclass list contains
|
||||
/// `BaseModel` / `pydantic.BaseModel`. Each `expression_statement` in
|
||||
|
|
@ -360,9 +352,7 @@ fn python_inherits_basemodel<'a>(class_node: Node<'a>, code: &'a [u8]) -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Walk helper
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn walk<'a, F: FnMut(Node<'a>)>(node: Node<'a>, f: &mut F) {
|
||||
f(node);
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ use crate::labels::{DataLabel, Kind, classify, lookup};
|
|||
use smallvec::SmallVec;
|
||||
use tree_sitter::Node;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Utility helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Return the text of a node.
|
||||
#[inline]
|
||||
|
|
@ -1018,10 +1016,10 @@ pub(crate) fn collect_idents(n: Node, code: &[u8], out: &mut Vec<String>) {
|
|||
/// AST kind names for subscript / index expressions
|
||||
/// across the languages whose container-element flow we model.
|
||||
///
|
||||
/// JS/TS use `subscript_expression`; Python uses `subscript`; Go uses
|
||||
/// `index_expression`. Other languages either lower indexing through
|
||||
/// method calls (Rust slice indexing) or are out of scope for the
|
||||
/// initial W5 rollout (Java/Ruby/PHP/C/C++).
|
||||
/// JS/TS and C/C++ use `subscript_expression`; Python uses `subscript`;
|
||||
/// Go uses `index_expression`. Other languages either lower indexing
|
||||
/// through method calls (Rust slice indexing) or are out of scope for
|
||||
/// the initial W5 rollout (Java/Ruby/PHP).
|
||||
#[inline]
|
||||
pub(crate) fn is_subscript_kind(kind: &str) -> bool {
|
||||
matches!(
|
||||
|
|
@ -1086,7 +1084,8 @@ pub(crate) fn subscript_components<'a>(n: Node<'a>, code: &'a [u8]) -> Option<(S
|
|||
return None;
|
||||
}
|
||||
let arr_text = text_of(arr, code)?;
|
||||
// PHP-style `$x` strip not needed here, Go/JS/Python don't use it.
|
||||
// PHP-style `$x` strip not needed here; the supported languages
|
||||
// don't use it for local array identifiers.
|
||||
let idx_text = text_of(idx, code)?;
|
||||
Some((arr_text, idx_text))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,9 +54,7 @@ pub(crate) fn collect_hierarchy_edges(
|
|||
acc
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Java
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_java<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mut F) {
|
||||
walk(root, &mut |node| {
|
||||
|
|
@ -146,9 +144,7 @@ fn type_identifier_text(n: Node<'_>, code: &[u8]) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Rust
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk for `impl_item` nodes and emit edges from the concrete type to
|
||||
/// the trait being implemented. Inherent impls (`impl Foo {}`) emit
|
||||
|
|
@ -199,9 +195,7 @@ fn rust_path_leaf(n: Node<'_>, code: &[u8]) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// TypeScript / JavaScript
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_ts<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mut F) {
|
||||
walk(root, &mut |node| {
|
||||
|
|
@ -268,9 +262,7 @@ fn collect_ts_heritage<F: FnMut(String, String)>(
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Python
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_python<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mut F) {
|
||||
walk(root, &mut |node| {
|
||||
|
|
@ -314,9 +306,7 @@ fn python_base_text(n: Node<'_>, code: &[u8]) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Ruby
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_ruby<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mut F) {
|
||||
walk(root, &mut |node| {
|
||||
|
|
@ -345,9 +335,7 @@ fn collect_ruby<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mu
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// PHP
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_php<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mut F) {
|
||||
walk(root, &mut |node| {
|
||||
|
|
@ -382,9 +370,7 @@ fn collect_php<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mut
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// C++
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_cpp<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mut F) {
|
||||
walk(root, &mut |node| {
|
||||
|
|
@ -419,9 +405,7 @@ fn collect_cpp<F: FnMut(String, String)>(root: Node<'_>, code: &[u8], push: &mut
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn walk<'a, F: FnMut(Node<'a>)>(node: Node<'a>, f: &mut F) {
|
||||
f(node);
|
||||
|
|
|
|||
|
|
@ -135,9 +135,7 @@ fn map_fs_module_to_promises(module: &str) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Import binding extraction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Walk the top-level AST nodes and collect import alias bindings:
|
||||
///
|
||||
|
|
@ -615,6 +613,4 @@ fn scoped_identifier_matches(node: Node, code: &[u8], crate_prefix: &str, leaf:
|
|||
(Some(p), Some(l)) if p == crate_prefix && l == leaf)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// === PUBLIC ENTRY POINT =================================================
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
//! Literal and constant-expression extraction from tree-sitter AST nodes.
|
||||
//!
|
||||
//! Parses integer and string literals, folds constant binary ops, and derives
|
||||
//! template/string prefixes and quote stripping for CFG construction and
|
||||
//! const propagation.
|
||||
|
||||
use super::conditions::unwrap_parens;
|
||||
use super::helpers::{collect_array_pattern_bindings_indexed, collect_rhs_array_literal_elements};
|
||||
use super::{
|
||||
|
|
@ -1198,10 +1204,22 @@ pub(super) fn is_syntactic_literal(node: Node, code: &[u8]) -> bool {
|
|||
| "string_content"
|
||||
| "string_fragment" => !has_string_interpolation(node),
|
||||
|
||||
// Numbers
|
||||
"integer" | "integer_literal" | "int_literal" | "float" | "float_literal" | "number" => {
|
||||
true
|
||||
}
|
||||
// Numbers. Java's grammar uses radix-tagged kinds
|
||||
// (`decimal_integer_literal`, `hex_integer_literal`, …) rather than a
|
||||
// bare `integer`, so `int num = 86;` would otherwise miss this arm and
|
||||
// lower to `Const(None)` (Varying) instead of `Const("86")`.
|
||||
"integer"
|
||||
| "integer_literal"
|
||||
| "int_literal"
|
||||
| "float"
|
||||
| "float_literal"
|
||||
| "number"
|
||||
| "decimal_integer_literal"
|
||||
| "hex_integer_literal"
|
||||
| "octal_integer_literal"
|
||||
| "binary_integer_literal"
|
||||
| "decimal_floating_point_literal"
|
||||
| "hex_floating_point_literal" => true,
|
||||
|
||||
// Booleans / null / nil / none
|
||||
"true" | "false" | "null" | "nil" | "none" | "null_literal" | "boolean"
|
||||
|
|
@ -2544,6 +2562,37 @@ pub(super) fn def_use(
|
|||
}
|
||||
}
|
||||
}
|
||||
// Java `enhanced_for_statement` binds the loop variable on the
|
||||
// `name` field and the iterable on the `value` field; Ruby's
|
||||
// `for x in coll` uses `pattern`/`value`. Neither uses the
|
||||
// JS/Python `left`/`right` convention, so without this mapping
|
||||
// the loop binding was never recorded as a define and taint on
|
||||
// the iterable could not reach the loop variable (OWASP's
|
||||
// dominant `for (Cookie c : req.getCookies())` shape).
|
||||
if left.is_none() && right.is_none() {
|
||||
if let Some(v) = ast.child_by_field_name("value") {
|
||||
left = ast
|
||||
.child_by_field_name("name")
|
||||
.or_else(|| ast.child_by_field_name("pattern"));
|
||||
right = Some(v);
|
||||
}
|
||||
}
|
||||
// PHP `foreach ($coll as $v)` / `foreach ($coll as $k => $v)`:
|
||||
// the iterable and binding are unnamed children separated by the
|
||||
// `as` keyword (only `body` is a named field). Map the binding
|
||||
// onto `left` and the iterable onto `right` so the shared
|
||||
// define/use logic below records the loop variable.
|
||||
if left.is_none() && right.is_none() && ast.kind() == "foreach_statement" {
|
||||
let mut cursor = ast.walk();
|
||||
let kids: Vec<Node> = ast.children(&mut cursor).collect();
|
||||
if let Some(as_pos) = kids.iter().position(|c| c.kind() == "as") {
|
||||
right = kids[..as_pos].iter().rev().find(|c| c.is_named()).copied();
|
||||
left = kids[as_pos + 1..]
|
||||
.iter()
|
||||
.find(|c| c.is_named() && lookup(lang, c.kind()) != Kind::Block)
|
||||
.copied();
|
||||
}
|
||||
}
|
||||
if left.is_none() && right.is_none() {
|
||||
// C-style for, defer to default ident collection.
|
||||
let mut idents = Vec::new();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue