Prerelease cleanup (#46)
* feat: Add const_bound_vars tracking to prevent false positives in ownership checks
* feat: Introduce field interner and typed bounded vars for enhanced type tracking
* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking
* feat: Centralize method name extraction with bare_method_name helper
* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch
* feat: Enhance C++ taint tracking with additional container operations and inline method resolution
* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking
* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis
* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations
* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details
* test: Add comprehensive tests for lattice algebra laws and SSA edge cases
* feat: Add destructured session user handling and safe user ID access patterns
* feat: Implement row-population reverse-walk for enhanced authorization checks
* feat: Enhance authorization checks with local alias chain for self-actor types
* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction
* feat: Implement chained method call inner-gate rebinding for SSRF prevention
* feat: Add observability and error modules, enhance debug functionality, and implement theme context
* feat: Remove Auth Analysis page and update navigation to redirect to Explorer
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity
* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build
The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(closure-capture): flip JS/TS fixtures to required-finding
The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.
Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".
Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis
* feat: Introduce health module and enhance health score computation with calibration tests
* feat: Add expectations configuration and cleanup .gitignore for log files
* feat: Implement theme selection and enhance settings panel for triage sync
* feat: Suppress false positives for strcpy calls with literal sources in AST
* feat: Update analyse_function_ssa to return body CFG for accurate analysis
* feat: Add bug report and feature request templates for improved issue tracking
* feat: removed dev scripts
* feat: update README.md for clarity and consistency in fixture descriptions
* feat: removed dev docs
* feat: clean up error handling and UI elements for improved user experience
* feat: adjust button sizes in HeaderBar for better UI consistency
* feat: enhance taint analysis with additional context for sanitizer and taint findings
* cargo fmt
* prettier
* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts
* feat: add script to frame PNG screenshots with brand gradient
* feat: add fuzzing support with new targets and CI workflows
* refactor: streamline match expressions and improve formatting in CLI and output handling
* feat: enhance configuration display with detailed output options
* feat: stage demo configuration for improved CLI screenshot output
* feat: expose merge_configs function for user-configurable settings
* refactor: simplify code structure and improve readability in config handling
* refactor: improve descriptions for vulnerability patterns in various languages
* feat: update MIT License section with additional usage details and copyright information
* feat: update screenshots
* refactor: update build process and paths for frontend assets
* feat: add cross-file taint fuzzing target and supporting dictionary
* refactor: clean up formatting and comments in fuzz configuration and example files
* refactor: remove outdated comments and clean up CI configuration files
* chore: update changelog dates and improve formatting in documentation
* refactor: update Cargo.toml and CI configuration for improved packaging and build process
* refactor: enhance quote-stripping logic to prevent panics and add regression tests
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* @elicpeter
|
||||
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
github: elicpeter
|
||||
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
name: Bug report
|
||||
description: Report a crash, incorrect output, or other broken behavior in Nyx.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to file a bug. **Please do not file security vulnerabilities here** — use the private advisory link in SECURITY.md.
|
||||
|
||||
For false positives or missed detections (rule quality), this is the right place — those are quality bugs.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One or two sentences describing what's wrong.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Minimal source snippet or repo + the exact `nyx` command you ran. The smaller, the better — ideally a single file.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: Include the finding (or lack of finding), error output, or stack trace.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Nyx version
|
||||
description: Output of `nyx --version`.
|
||||
placeholder: "nyx 0.5.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: OS / arch
|
||||
placeholder: "macOS 14.5 arm64"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: language
|
||||
attributes:
|
||||
label: Target language (if applicable)
|
||||
options:
|
||||
- "n/a"
|
||||
- JavaScript / TypeScript
|
||||
- Python
|
||||
- Java
|
||||
- Go
|
||||
- Ruby
|
||||
- PHP
|
||||
- Rust
|
||||
- C / C++
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Logs, screenshots, related issues — anything else that helps.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/elicpeter/nyx/security/advisories/new
|
||||
about: Do NOT file public issues for security bugs. Use private disclosure (see SECURITY.md).
|
||||
- name: Question or discussion
|
||||
url: https://github.com/elicpeter/nyx/discussions
|
||||
about: Open-ended questions, ideas, or help using Nyx belong in Discussions.
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Feature request
|
||||
description: Suggest a new capability, rule, language, or UX improvement.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What are you trying to do that Nyx can't do today? Concrete scenarios beat abstract wishes.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How should it work? Sketches, example commands, or example findings are welcome.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other approaches you've thought about, and why they don't fit.
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
## Summary
|
||||
|
||||
<!-- What does this PR change, and why? Keep it short. The diff already shows the "what". -->
|
||||
|
||||
## Related issues
|
||||
|
||||
<!-- "Closes #123", "Refs #456". Delete if none. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `cargo test --bin nyx` passes
|
||||
- [ ] `cargo clippy --all -- -D warnings` is clean
|
||||
- [ ] `cargo fmt -- --check` passes
|
||||
- [ ] User-visible changes are noted in `CHANGELOG.md` under `## [Unreleased]`
|
||||
- [ ] Docs updated if behavior, flags, or config changed (`docs/`, `README.md`, `CONTRIBUTING.md`)
|
||||
- [ ] New rules / language support include fixtures and integration tests
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything you want a reviewer to look at first, tradeoffs, follow-ups. Delete if none. -->
|
||||
33
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
cargo-minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
actions-minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
frontend-minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
65
.github/workflows/ci.yml
vendored
|
|
@ -50,6 +50,10 @@ jobs:
|
|||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
- name: Frontend build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
rustfmt:
|
||||
name: rustfmt
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -96,6 +100,14 @@ jobs:
|
|||
- name: License & advisory checks
|
||||
run: cargo deny check advisories licenses bans sources
|
||||
|
||||
unused-deps:
|
||||
name: unused-deps
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: bnjbvr/cargo-machete@v0.9.2
|
||||
|
||||
third-party-licenses:
|
||||
name: third-party-licenses
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -155,6 +167,20 @@ jobs:
|
|||
- name: Beta compile compatibility check
|
||||
run: cargo check --all-features --tests
|
||||
|
||||
msrv:
|
||||
name: msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: "1.88"
|
||||
cache: true
|
||||
|
||||
- name: Compile check at MSRV
|
||||
run: cargo check --all-features --tests
|
||||
|
||||
rust-stable-test:
|
||||
name: rust-stable-test
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -210,10 +236,44 @@ jobs:
|
|||
- name: Rust tests (beta)
|
||||
run: cargo nextest run --all-features
|
||||
|
||||
cargo-package:
|
||||
name: cargo-package
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Verify dist embedded in package
|
||||
run: |
|
||||
for f in src/server/assets/dist/index.html src/server/assets/dist/app.js src/server/assets/dist/style.css src/server/assets/favicon.svg default-nyx.conf build.rs; do
|
||||
if ! cargo package --list --allow-dirty | grep -qx "$f"; then
|
||||
echo "::error::missing from cargo package: $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: cargo package (verify build)
|
||||
run: cargo package --allow-dirty
|
||||
|
||||
benchmark-gate:
|
||||
name: benchmark-gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
|
|
@ -223,6 +283,9 @@ jobs:
|
|||
cache: true
|
||||
cache-key: benchmark-gate-release
|
||||
|
||||
- name: Build benchmark + perf test binaries
|
||||
run: cargo test --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
|
||||
|
||||
|
|
|
|||
2
.github/workflows/codeql.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
|||
pull_request:
|
||||
branches: ["master"]
|
||||
schedule:
|
||||
- cron: "28 20 * * 2"
|
||||
- cron: "0 9 * * 2"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
|
|
|||
30
.github/workflows/dependabot-auto-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: Dependabot auto-merge
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip fork PRs entirely (the merge would fail anyway, but no need to run).
|
||||
if: >-
|
||||
github.event.pull_request.user.login == 'dependabot[bot]' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Enable auto-merge for patch and minor updates
|
||||
if: >-
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
8
.github/workflows/docs.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
|
||||
- name: Cache mdbook
|
||||
id: cache-mdbook
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cargo/bin/mdbook
|
||||
key: mdbook-0.5.2-${{ runner.os }}
|
||||
|
|
@ -36,15 +36,13 @@ jobs:
|
|||
if: steps.cache-mdbook.outputs.cache-hit != 'true'
|
||||
run: cargo install mdbook --version 0.5.2 --locked
|
||||
|
||||
# mdbook follows the committed docs/assets symlink (→ ../assets) so
|
||||
# image references in docs resolve both in `mdbook serve` and in CI.
|
||||
- name: Build
|
||||
run: mdbook build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: book
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
|
|
|||
148
.github/workflows/fuzz.yml
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
name: Fuzz
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "fuzz/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".github/workflows/fuzz.yml"
|
||||
schedule:
|
||||
# Long-form weekly run, Sundays at 06:00 UTC.
|
||||
- cron: "0 6 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fuzz:
|
||||
name: fuzz-${{ matrix.target }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [scan_bytes, extract_summaries, cross_file_taint]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# cargo-fuzz needs nightly for the libFuzzer codegen flags.
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
cache: true
|
||||
cache-workspaces: |
|
||||
.
|
||||
fuzz
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
|
||||
- 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: Restore fuzz corpus
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: fuzz/corpus/${{ matrix.target }}
|
||||
key: fuzz-corpus-${{ matrix.target }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
fuzz-corpus-${{ matrix.target }}-
|
||||
|
||||
# The harness reads inputs as <lang_idx_byte><source>, so we prefix
|
||||
# each seed with its language index here at stage time. Files in
|
||||
# fuzz/seed_corpus/ are committed as plain source without the byte
|
||||
# because some IDEs strip 0x00 on save.
|
||||
- name: Layer seed corpus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
target=${{ matrix.target }}
|
||||
dest="fuzz/corpus/$target"
|
||||
mkdir -p "$dest"
|
||||
ext_to_idx() {
|
||||
case "$1" in
|
||||
rs) echo 0 ;;
|
||||
js) echo 1 ;;
|
||||
ts) echo 2 ;;
|
||||
py) echo 3 ;;
|
||||
go) echo 4 ;;
|
||||
java) echo 5 ;;
|
||||
rb) echo 6 ;;
|
||||
php) echo 7 ;;
|
||||
c) echo 8 ;;
|
||||
cpp) echo 9 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
stage() {
|
||||
src="$1"
|
||||
ext="${src##*.}"
|
||||
idx=$(ext_to_idx "$ext") || return 0
|
||||
hash=$(sha256sum "$src" | cut -c1-16)
|
||||
out="$dest/seed-${ext}-${hash}"
|
||||
[ -e "$out" ] && return 0
|
||||
printf '%b' "$(printf '\\%03o' "$idx")" > "$out"
|
||||
cat "$src" >> "$out"
|
||||
}
|
||||
for f in benches/fixtures/sample.*; do
|
||||
[ -e "$f" ] && stage "$f"
|
||||
done
|
||||
while IFS= read -r f; do
|
||||
stage "$f"
|
||||
done < <(find tests/benchmark/corpus -type f \( \
|
||||
-name '*.rs' -o -name '*.js' -o -name '*.ts' \
|
||||
-o -name '*.py' -o -name '*.go' -o -name '*.java' \
|
||||
-o -name '*.rb' -o -name '*.php' -o -name '*.c' \
|
||||
-o -name '*.cpp' \))
|
||||
if [ -d "fuzz/seed_corpus/$target" ]; then
|
||||
while IFS= read -r f; do
|
||||
stage "$f"
|
||||
done < <(find "fuzz/seed_corpus/$target" -type f \( \
|
||||
-name '*.rs' -o -name '*.js' -o -name '*.ts' \
|
||||
-o -name '*.py' -o -name '*.go' -o -name '*.java' \
|
||||
-o -name '*.rb' -o -name '*.php' -o -name '*.c' \
|
||||
-o -name '*.cpp' \))
|
||||
fi
|
||||
echo "Corpus dir: $(ls "$dest" | wc -l) files"
|
||||
|
||||
- name: Choose fuzz duration
|
||||
id: budget
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "seconds=18000" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "seconds=600" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Run fuzz target
|
||||
run: |
|
||||
cargo fuzz run --target x86_64-unknown-linux-gnu ${{ matrix.target }} -- \
|
||||
-max_total_time=${{ steps.budget.outputs.seconds }} \
|
||||
-max_len=65536 \
|
||||
-timeout=60 \
|
||||
-dict=fuzz/dict/all.dict
|
||||
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fuzz-artifacts-${{ matrix.target }}-${{ github.run_id }}
|
||||
path: fuzz/artifacts/${{ matrix.target }}/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
130
.github/workflows/release-build.yml
vendored
|
|
@ -11,7 +11,37 @@ env:
|
|||
BIN_NAME: nyx
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out sources
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Upload frontend dist
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: src/server/assets/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
needs: frontend
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
|
|
@ -31,6 +61,12 @@ jobs:
|
|||
- name: Check out sources
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download prebuilt frontend dist
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: src/server/assets/dist/
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
|
|
@ -52,12 +88,6 @@ jobs:
|
|||
- name: Build
|
||||
run: cargo build --release --bin ${{ env.BIN_NAME }} --target ${{ matrix.target }}
|
||||
|
||||
# THIRDPARTY-LICENSES.html is committed at the repo root and kept in
|
||||
# sync with the dependency graph by the `third-party-licenses` CI
|
||||
# job. Release builds ship the committed copy directly — no
|
||||
# regeneration (and no per-runner cargo-about install) on the
|
||||
# release hot path.
|
||||
|
||||
- name: Package (Linux & macOS)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
|
|
@ -83,7 +113,6 @@ jobs:
|
|||
New-Item -ItemType Directory -Path dist -Force | Out-Null
|
||||
$Archive = "$Bin-$Target.zip"
|
||||
|
||||
# PowerShell’s native ZIP
|
||||
Compress-Archive `
|
||||
-Path $BinPath, 'THIRDPARTY-LICENSES.html', 'LICENSE*', 'COPYING*' `
|
||||
-DestinationPath "dist/$Archive" `
|
||||
|
|
@ -100,18 +129,20 @@ jobs:
|
|||
retention-days: 1
|
||||
|
||||
reproducibility:
|
||||
# Supply-chain smoke test: build the release binary twice with pinned
|
||||
# SOURCE_DATE_EPOCH and path remapping, then diff the SHA256 hashes.
|
||||
# Gates `publish` so non-reproducible builds cannot ship. Scoped to
|
||||
# x86_64-linux — the most tractable target for byte-for-byte
|
||||
# determinism; failures on other targets would be investigated
|
||||
# separately.
|
||||
name: reproducibility-check
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out sources
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download prebuilt frontend dist
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: src/server/assets/dist/
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
|
|
@ -151,28 +182,17 @@ jobs:
|
|||
echo "::notice::Reproducible build verified (sha256=$HASH1)"
|
||||
|
||||
publish:
|
||||
# Collect all matrix build outputs, generate a single SHA256SUMS file,
|
||||
# then push everything to the GitHub release in one shot. Doing this
|
||||
# centrally (rather than per-matrix job) is the only way to produce a
|
||||
# checksum file that covers every published artifact.
|
||||
name: publish-release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, reproducibility]
|
||||
needs: [build]
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
steps:
|
||||
- name: Check out sources
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Generate the SBOM from the source tree BEFORE downloading
|
||||
# artifacts. Syft scans `path: .` recursively; if release-artifacts/
|
||||
# exists at scan time, it would walk into the zipped binaries and
|
||||
# produce a polluted manifest.
|
||||
- name: Generate CycloneDX SBOM
|
||||
uses: anchore/sbom-action@v0
|
||||
with:
|
||||
|
|
@ -197,36 +217,35 @@ jobs:
|
|||
sha256sum *.zip > SHA256SUMS
|
||||
cat SHA256SUMS
|
||||
|
||||
- name: Import GPG signing key
|
||||
if: env.GPG_PRIVATE_KEY != ''
|
||||
# Sigstore keyless signing. Verify with:
|
||||
# cosign verify-blob --certificate <file>.pem \
|
||||
# --signature <file>.sig \
|
||||
# --certificate-identity-regexp 'https://github.com/elicpeter/nyx/.*' \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
# <file>
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@v4.1.1
|
||||
|
||||
- name: Cosign keyless sign release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
gpg --list-secret-keys --keyid-format=long
|
||||
SBOM="nyx-${{ github.event.release.tag_name }}.cdx.json"
|
||||
(
|
||||
cd release-artifacts
|
||||
for f in *.zip SHA256SUMS; do
|
||||
cosign sign-blob --yes \
|
||||
--output-signature "$f.sig" \
|
||||
--output-certificate "$f.pem" \
|
||||
"$f"
|
||||
done
|
||||
)
|
||||
cosign sign-blob --yes \
|
||||
--output-signature "$SBOM.sig" \
|
||||
--output-certificate "$SBOM.pem" \
|
||||
"$SBOM"
|
||||
|
||||
- name: Sign SHA256SUMS
|
||||
if: env.GPG_PRIVATE_KEY != ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-artifacts
|
||||
if [ -n "${GPG_PASSPHRASE:-}" ]; then
|
||||
printf '%s' "$GPG_PASSPHRASE" \
|
||||
| gpg --batch --yes --pinentry-mode loopback \
|
||||
--passphrase-fd 0 --armor --detach-sign SHA256SUMS
|
||||
else
|
||||
gpg --batch --yes --armor --detach-sign SHA256SUMS
|
||||
fi
|
||||
ls -l SHA256SUMS.asc
|
||||
|
||||
- name: Warn if GPG signing was skipped
|
||||
if: env.GPG_PRIVATE_KEY == ''
|
||||
run: |
|
||||
echo "::warning::GPG_PRIVATE_KEY secret not configured; SHA256SUMS will ship unsigned. Add GPG_PRIVATE_KEY (ASCII-armored) and optional GPG_PASSPHRASE to repository secrets to enable signed checksums."
|
||||
|
||||
# SLSA v1 build provenance: signed attestation that these exact
|
||||
# bytes were produced by this workflow run from this commit.
|
||||
# Attestations are stored in the GitHub attestations API and can
|
||||
# be verified with `gh attestation verify <file> --repo <repo>`.
|
||||
# SLSA v1 provenance. Verify with `gh attestation verify <file> --repo <repo>`.
|
||||
- name: Generate SLSA build provenance
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
|
|
@ -240,8 +259,13 @@ jobs:
|
|||
with:
|
||||
files: |
|
||||
release-artifacts/*.zip
|
||||
release-artifacts/*.zip.sig
|
||||
release-artifacts/*.zip.pem
|
||||
release-artifacts/SHA256SUMS
|
||||
release-artifacts/SHA256SUMS.asc
|
||||
release-artifacts/SHA256SUMS.sig
|
||||
release-artifacts/SHA256SUMS.pem
|
||||
nyx-${{ github.event.release.tag_name }}.cdx.json
|
||||
nyx-${{ github.event.release.tag_name }}.cdx.json.sig
|
||||
nyx-${{ github.event.release.tag_name }}.cdx.json.pem
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
45
.github/workflows/scorecard.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: OSSF Scorecard
|
||||
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: "0 7 * * 1"
|
||||
push:
|
||||
branches: ["master"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: scorecard
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run analysis
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# Flip to true once we're happy with the score and want the badge.
|
||||
publish_results: false
|
||||
|
||||
- name: Upload SARIF artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: scorecard-sarif
|
||||
path: results.sarif
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload SARIF to Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
6
.gitignore
vendored
|
|
@ -1,7 +1,13 @@
|
|||
/target
|
||||
/fuzz/target
|
||||
/fuzz/corpus
|
||||
/fuzz/artifacts
|
||||
/.idea
|
||||
/frontend/node_modules
|
||||
/src/server/assets/dist
|
||||
/.nyx
|
||||
/logs
|
||||
/book
|
||||
.DS_Store
|
||||
.z3-trace
|
||||
.node_modules-target
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"version": 1,
|
||||
"decisions": [],
|
||||
"suppression_rules": []
|
||||
}
|
||||
28
CHANGELOG.md
|
|
@ -6,7 +6,7 @@ All notable changes to Nyx are documented here. The format is based on [Keep a C
|
|||
|
||||
_No changes yet._
|
||||
|
||||
## [0.5.0] — 2026-04-24
|
||||
## [0.5.0] - 2026-04-29
|
||||
|
||||
The biggest release since launch. The taint engine was rebuilt on top of an SSA IR, cross-file analysis was deepened across the board, and Nyx now ships a local web UI for triaging findings without leaving your machine.
|
||||
|
||||
|
|
@ -24,13 +24,13 @@ The biggest release since launch. The taint engine was rebuilt on top of an SSA
|
|||
### Engine
|
||||
|
||||
- SSA IR with dominance-frontier phi insertion. The optimization pipeline runs constant propagation, branch pruning, copy propagation, alias analysis, DCE, type facts, and points-to in sequence.
|
||||
- Multi-label classification — a single API can carry both Source and Sink labels (e.g. PHP `file_get_contents`, Java `readObject`).
|
||||
- Gated sinks — `setAttribute`, `parseFromString`, etc. only activate when the constant attribute argument is dangerous, and only the payload argument is treated as taint-bearing.
|
||||
- Multi-label classification. A single API can carry both Source and Sink labels (e.g. PHP `file_get_contents`, Java `readObject`).
|
||||
- Gated sinks. `setAttribute`, `parseFromString`, etc. only activate when the constant attribute argument is dangerous, and only the payload argument is treated as taint-bearing.
|
||||
- Container taint with per-index precision and bounded points-to. Aliased containers share heap identity correctly.
|
||||
- Loop-aware analysis: induction-variable pruning, widening at loop heads, bounded unrolling in symex.
|
||||
- Path-sensitive phi evaluation propagates validation when all tainted predecessors are guarded.
|
||||
- Per-return-path summaries decompose function effects when paths produce different taint behavior.
|
||||
- Cross-file SCC fixed-point — mutually recursive functions across files now reach a joint convergence.
|
||||
- Cross-file SCC fixed-point. Mutually recursive functions across files now reach a joint convergence.
|
||||
- Demand-driven backwards analysis (off by default) annotates findings with cutoff diagnostics.
|
||||
- Direction-aware engine notes (`UnderReport`, `OverReport`, `Bail`) flow into confidence scoring, ranking, and the new `--require-converged` strict mode.
|
||||
|
||||
|
|
@ -56,11 +56,11 @@ The biggest release since launch. The taint engine was rebuilt on top of an SSA
|
|||
|
||||
### CLI & Output
|
||||
|
||||
- `nyx serve` — local web UI on `localhost` only (refuses non-loopback binds).
|
||||
- `nyx serve`: local web UI on `localhost` only (refuses non-loopback binds).
|
||||
- `--require-converged` filters out findings where the engine bailed early.
|
||||
- Analysis-engine toggles graduated from `NYX_*` env vars to first-class flags and `[analysis.engine]` config: `--constraint-solving`, `--abstract-interp`, `--context-sensitive`, `--symex`, `--cross-file-symex`, `--symex-interproc`, `--smt`, `--parse-timeout-ms`. Old env vars still work when Nyx is consumed as a library.
|
||||
- Confidence (`High`/`Medium`/`Low`) shown on every finding, including console headers.
|
||||
- Engine notes surfaced in console (`[capped: N notes — over-report]`), JSON (`engine_notes`, `confidence_capped`), and SARIF (`result.properties.loss_direction`).
|
||||
- Engine notes surfaced in console (`[capped: N notes, over-report]`), JSON (`engine_notes`, `confidence_capped`), and SARIF (`result.properties.loss_direction`).
|
||||
- Flow paths reconstructed step-by-step with file/line/snippet for each hop.
|
||||
- Concrete attack witness strings synthesized by the symbolic executor.
|
||||
- Primary sink locations now point at the callee's real sink line; caller call sites are preserved as flow steps.
|
||||
|
|
@ -95,7 +95,7 @@ The biggest release since launch. The taint engine was rebuilt on top of an SSA
|
|||
- Legacy BFS taint engine, `TaintTransfer`, `TaintState`, and the `NYX_LEGACY` fallback.
|
||||
- Legacy vanilla-JS frontend (`app.js`).
|
||||
|
||||
## [0.4.0] — 2025-02-25
|
||||
## [0.4.0] - 2026-02-25
|
||||
|
||||
A precision and ergonomics release. Findings are now ranked, lower-noise by default, and easier to triage in CI.
|
||||
|
||||
|
|
@ -126,19 +126,19 @@ A precision and ergonomics release. Findings are now ranked, lower-noise by defa
|
|||
|
||||
### Breaking
|
||||
|
||||
- Config and data directory renamed from `dev.ecpeter23.nyx` to `nyx`. Existing config and SQLite indexes at the old path won't be picked up — copy them across or re-run `nyx scan`.
|
||||
- Config and data directory renamed from `dev.ecpeter23.nyx` to `nyx`. Existing config and SQLite indexes at the old path won't be picked up. Copy them across or re-run `nyx scan`.
|
||||
- `Severity::from_str` now returns `Err` for unknown values instead of silently defaulting to Low.
|
||||
|
||||
### Notable Fixes
|
||||
|
||||
- KINDS-map audit across all 10 languages: 89 missing tree-sitter node types added. Switch/case, try/catch/finally, class bodies, lambdas, closures, and namespaces are no longer silently dropped.
|
||||
- `else_clause` mapping fixed for C, C++, Rust, JS, TS, Python, PHP — code inside else blocks was being dropped from the CFG.
|
||||
- `else_clause` mapping fixed for C, C++, Rust, JS, TS, Python, PHP. Code inside else blocks was being dropped from the CFG.
|
||||
- Rust `if let` / `while let` taint propagation now works.
|
||||
- Taint BFS non-termination on large JS files (the BFS engine has since been replaced).
|
||||
- C++ `popen` pattern ID collision with C.
|
||||
- Constant-arg sink suppression for AST patterns.
|
||||
|
||||
## [0.3.0] — 2026-02-25
|
||||
## [0.3.0] - 2026-02-25
|
||||
|
||||
Configurability, SARIF, and an aggressive false-positive purge.
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ Configurability, SARIF, and an aggressive false-positive purge.
|
|||
- `freopen` no longer matches `fopen` acquire patterns.
|
||||
- Struct-field, linked-list, and global assignment recognized as ownership transfers.
|
||||
|
||||
## [0.2.0] — 2026-02-24
|
||||
## [0.2.0] - 2026-02-24
|
||||
|
||||
The cross-file release.
|
||||
|
||||
|
|
@ -192,19 +192,19 @@ The cross-file release.
|
|||
- Performance: read-once/hash-once via `_from_bytes` variants, lock-free rayon, SQLite WAL + 8 MB cache + 256 MB mmap.
|
||||
- Tracing instrumentation on all pipeline stages; criterion benchmark suite.
|
||||
|
||||
## [0.2.0-alpha] — 2025-06-28
|
||||
## [0.2.0-alpha] - 2025-06-28
|
||||
|
||||
- Experimental intra-procedural CFG + taint analysis for Rust. Builds a CFG, applies dataflow, and flags unsanitised Source → Sink paths (e.g. `env::var` → `Command::new`).
|
||||
- O(1) node-kind lookup via per-language PHF tables.
|
||||
- Debug channel `target=cfg` (`RUST_LOG=nyx::cfg=debug`) to inspect generated graphs.
|
||||
- Fixed Windows release pipeline (PowerShell has no `zip` command).
|
||||
|
||||
## [0.1.1-alpha] — 2025-06-25
|
||||
## [0.1.1-alpha] - 2025-06-25
|
||||
|
||||
- Fixed `scan --no-index` not respecting the `max_results` config setting (#1).
|
||||
- Integration tests covering indexing and scanning pipelines (#3, #4, #5, #8).
|
||||
|
||||
## [0.1.0-alpha] — 2025-06-25
|
||||
## [0.1.0-alpha] - 2025-06-25
|
||||
|
||||
Initial alpha release.
|
||||
|
||||
|
|
|
|||
2
CLA.md
|
|
@ -18,7 +18,7 @@ By submitting a Contribution to the Project, You accept and agree to the terms b
|
|||
|
||||
**"You"** (or **"Your"**) means the individual or legal entity making a Contribution to the Project. For a legal entity, "You" includes the entity and any entity that controls, is controlled by, or is under common control with that entity.
|
||||
|
||||
**"Contribution"** means any work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, the Project. "Submitted" means any form of electronic, verbal, or written communication sent to the Project — including but not limited to pull requests, patches, and issue comments — but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
**"Contribution"** means any work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, the Project. "Submitted" means any form of electronic, verbal, or written communication sent to the Project (including but not limited to pull requests, patches, and issue comments) but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
|
||||
## 2. Copyright License Grant
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Thank you for your interest in improving Nyx. This guide covers everything you need to contribute effectively.
|
||||
|
||||
User-facing documentation lives at **[elicpeter.github.io/nyx](https://elicpeter.github.io/nyx/)**; the source for those pages is in [`docs/`](docs/).
|
||||
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating.
|
||||
|
||||
---
|
||||
|
|
@ -43,7 +45,7 @@ cargo install --path . # Install as `nyx` binary
|
|||
|
||||
```bash
|
||||
cargo test --bin nyx # Unit tests (inline in modules)
|
||||
cargo clippy --all -- -D warnings # Lint — treats warnings as errors
|
||||
cargo clippy --all -- -D warnings # Lint, treats warnings as errors
|
||||
cargo fmt # Format code
|
||||
cargo fmt -- --check # Check formatting without modifying
|
||||
```
|
||||
|
|
@ -64,14 +66,12 @@ Benchmark fixtures live in `benches/fixtures/`. Criterion produces HTML reports
|
|||
|
||||
```
|
||||
src/
|
||||
main.rs CLI entry point
|
||||
main.rs CLI entry point
|
||||
lib.rs Library re-exports (benchmarks, integration tests)
|
||||
cli.rs Clap command definitions
|
||||
commands/
|
||||
mod.rs Command dispatch
|
||||
scan.rs Two-pass scan orchestration, Diag struct
|
||||
commands/ Subcommand handlers (scan, index, list, clean, config, serve)
|
||||
ast.rs Entry points for both passes; tree-sitter parsing
|
||||
cfg.rs CFG construction from AST
|
||||
cfg/ CFG construction from AST, type hierarchy
|
||||
cfg_analysis/ CFG structural detectors
|
||||
guards.rs Unguarded sink detection (dominator analysis)
|
||||
auth.rs Auth gap detection
|
||||
|
|
@ -79,33 +79,36 @@ src/
|
|||
error_handling.rs Error fallthrough detection
|
||||
unreachable.rs Unreachable security code detection
|
||||
rules.rs Guard rules, auth rules, resource pairs
|
||||
taint/
|
||||
mod.rs Taint analysis facade + JS two-level solve
|
||||
domain.rs TaintState lattice (VarTaint, Cap, TaintOrigin)
|
||||
transfer.rs TaintTransfer function (source/sanitizer/sink/call)
|
||||
ssa/ SSA IR (lowering, optimization passes, const prop)
|
||||
taint/ SSA-based taint engine (sole engine since 0.5.0)
|
||||
mod.rs Facade + JS two-level solve
|
||||
domain.rs Shared lattice types (VarTaint, Cap, TaintOrigin)
|
||||
ssa_transfer/ Block-level worklist, k=1 inline cache, gated sinks
|
||||
backwards.rs Demand-driven backwards taint walk (opt-in)
|
||||
path_state.rs Predicate tracking and contradiction pruning
|
||||
state/
|
||||
engine.rs Generic monotone dataflow engine (Transfer<S: Lattice>)
|
||||
transfer.rs DefaultTransfer — resource lifecycle + auth state
|
||||
summary.rs FuncSummary, GlobalSummaries, conservative merge
|
||||
labels/ Per-language label rules
|
||||
mod.rs classify() dispatch, Cap bitflags, DataLabel, LabelRule
|
||||
rust.rs Rust sources, sinks, sanitizers
|
||||
javascript.rs JS sources, sinks, sanitizers
|
||||
... (one file per language)
|
||||
patterns/ Per-language AST pattern queries
|
||||
mod.rs Pattern struct, Severity, SeverityFilter, registry
|
||||
rust.rs Rust patterns
|
||||
javascript.rs JS patterns
|
||||
... (one file per language)
|
||||
transfer.rs DefaultTransfer: resource lifecycle + auth state
|
||||
summary/ FuncSummary, SsaFuncSummary, GlobalSummaries, hierarchy index
|
||||
abstract_interp/ Interval + string prefix/suffix domains
|
||||
pointer/ Field-sensitive points-to (Steensgaard-style)
|
||||
symex/ Symbolic execution + witness generation
|
||||
constraint/ Path-constraint solving (optional Z3 via `smt` feature)
|
||||
auth_analysis/ Rust auth rule (`rs.auth.missing_ownership_check`) + sink classes
|
||||
suppress/ Inline `nyx:ignore` directive parsing
|
||||
labels/ Per-language label rules (one file per language)
|
||||
patterns/ Per-language AST pattern queries (one file per language)
|
||||
callgraph.rs Call graph construction (petgraph), SCC, topo sort
|
||||
database.rs SQLite indexing via r2d2 pool
|
||||
rank.rs Attack-surface ranking
|
||||
fmt.rs Output formatting and evidence normalization
|
||||
fmt.rs Console output formatting
|
||||
output.rs SARIF 2.1 builder
|
||||
walk.rs Parallel file walker (ignore crate, respects .gitignore)
|
||||
symbol.rs Symbol interning (SymbolId)
|
||||
symbol/ Symbol interning (SymbolId)
|
||||
server/ `nyx serve` HTTP layer, routes, triage sync
|
||||
interop.rs Cross-language interop edges
|
||||
engine_notes.rs Direction-aware engine notes (UnderReport / OverReport / Bail)
|
||||
evidence.rs Structured evidence emitted with each finding
|
||||
errors.rs NyxError, NyxResult types
|
||||
utils/
|
||||
config.rs TOML config loading, merging, Config struct
|
||||
|
|
@ -135,7 +138,7 @@ AST patterns are the simplest detector to add. Each pattern is a tree-sitter que
|
|||
```rust
|
||||
Pattern {
|
||||
id: "py.cmdi.os_popen",
|
||||
description: "os.popen() — shell command execution",
|
||||
description: "os.popen() shell command execution",
|
||||
query: r#"(call
|
||||
function: (attribute
|
||||
object: (identifier) @pkg (#eq? @pkg "os")
|
||||
|
|
@ -246,8 +249,8 @@ Adding a new language requires changes across several modules. Use an existing l
|
|||
6. **AST patterns**: Create `src/patterns/<lang>.rs` with a `PATTERNS` constant.
|
||||
|
||||
7. **Registry updates**:
|
||||
- `src/patterns/mod.rs` — add to the `REGISTRY` HashMap
|
||||
- `src/labels/mod.rs` — add to the `classify()` dispatch
|
||||
- `src/patterns/mod.rs`: add to the `REGISTRY` HashMap
|
||||
- `src/labels/mod.rs`: add to the `classify()` dispatch
|
||||
|
||||
8. **File extension mapping**: Add the extension in `ast.rs`.
|
||||
|
||||
|
|
@ -316,10 +319,10 @@ First-time contributors are welcome. If you are unsure where to start, open an i
|
|||
|
||||
Please [open an issue](https://github.com/elicpeter/nyx/issues) for:
|
||||
|
||||
- **Crashes or panics** — include the backtrace (`RUST_BACKTRACE=1 nyx scan .`)
|
||||
- **False positives** — include the minimal code snippet, rule ID, and Nyx version
|
||||
- **False negatives** — describe what you expected Nyx to find and why
|
||||
- **Documentation errors** — point to the specific page and what's wrong
|
||||
- **Crashes or panics**: include the backtrace (`RUST_BACKTRACE=1 nyx scan .`)
|
||||
- **False positives**: include the minimal code snippet, rule ID, and Nyx version
|
||||
- **False negatives**: describe what you expected Nyx to find and why
|
||||
- **Documentation errors**: point to the specific page and what's wrong
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -327,9 +330,9 @@ Please [open an issue](https://github.com/elicpeter/nyx/issues) for:
|
|||
|
||||
We welcome well-motivated feature proposals. Please describe:
|
||||
|
||||
1. **Problem statement** — what pain point does this solve?
|
||||
2. **Proposed solution** — high-level description, optionally with pseudo-code.
|
||||
3. **Alternatives considered** — why existing functionality is not enough.
|
||||
1. **Problem statement**: what pain point does this solve?
|
||||
2. **Proposed solution**: high-level description, optionally with pseudo-code.
|
||||
3. **Alternatives considered**: why existing functionality is not enough.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
20
Cargo.lock
generated
|
|
@ -1160,6 +1160,7 @@ dependencies = [
|
|||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"rayon",
|
||||
"rmp-serde",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1532,6 +1533,25 @@ version = "0.8.10"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
38
Cargo.toml
|
|
@ -8,39 +8,36 @@ license = "GPL-3.0-or-later"
|
|||
authors = ["Eli Peter <elicpeter@example.com>"]
|
||||
homepage = "https://github.com/elicpeter/nyx"
|
||||
repository = "https://github.com/elicpeter/nyx"
|
||||
documentation = "https://github.com/elicpeter/nyx/tree/master/docs"
|
||||
documentation = "https://elicpeter.github.io/nyx/"
|
||||
keywords = ["security", "vulnerability", "scanner", "static-analysis", "cli"]
|
||||
categories = ["security", "command-line-utilities", "development-tools", "parser-implementations", "text-processing"]
|
||||
readme = "README.md"
|
||||
default-run = "nyx"
|
||||
exclude = [
|
||||
"assets/",
|
||||
"frontend/node_modules/",
|
||||
".github/",
|
||||
"CLAUDE.md",
|
||||
".claude/",
|
||||
".idea/",
|
||||
"tests/",
|
||||
"benches/",
|
||||
"docs/",
|
||||
".DS_Store",
|
||||
".nyx/",
|
||||
".z3-trace",
|
||||
"target/",
|
||||
"book/",
|
||||
include = [
|
||||
"/src/**",
|
||||
"/tools/**",
|
||||
"/build.rs",
|
||||
"/Cargo.toml",
|
||||
"/Cargo.lock",
|
||||
"/README.md",
|
||||
"/LICENSE",
|
||||
"/THIRDPARTY-LICENSES.html",
|
||||
"/default-nyx.conf",
|
||||
]
|
||||
|
||||
autoexamples = false
|
||||
|
||||
|
||||
[package.metadata.binstall]
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/nyx-{ target }{ archive-suffix }"
|
||||
pkg-fmt = "zip"
|
||||
bin-dir = "target/{ target }/release/{ bin }{ binary-ext }"
|
||||
|
||||
[features]
|
||||
default = ["serve"]
|
||||
serve = ["dep:axum", "dep:tokio", "dep:tokio-stream", "dep:tower-http"]
|
||||
smt = ["dep:z3", "z3/bundled"]
|
||||
smt-system-z3 = ["dep:z3"]
|
||||
# Build switch for the internal `nyx-docgen` tool. Empty on purpose: it
|
||||
# only gates the [[bin]] target so consumers of `cargo install nyx-scanner`
|
||||
# don't pick up the docgen binary. Maintainers run it via
|
||||
# `cargo run --features docgen --bin nyx-docgen`.
|
||||
docgen = []
|
||||
|
||||
[lib]
|
||||
|
|
@ -73,6 +70,7 @@ directories = "6.0.0"
|
|||
clap = { version = "4.5.60", features = ["derive"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
rmp-serde = "1.3"
|
||||
toml = "1.0.3"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json", "ansi","time"] }
|
||||
tracing = "0.1.44"
|
||||
|
|
|
|||
45
README.md
|
|
@ -7,9 +7,10 @@
|
|||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://github.com/elicpeter/nyx/actions)
|
||||
[](https://elicpeter.github.io/nyx/)
|
||||
</div>
|
||||
|
||||
<p align="center"><img src="assets/screenshots/demo.gif" alt="Nyx UI walkthrough: scan, browse findings, inspect flow path, triage" width="900"/></p>
|
||||
<p align="center"><img src="assets/screenshots/demo.gif" alt="Nyx UI walkthrough: empty Welcome state, kicking off a scan, the populated overview with Health Score, drilling into a HIGH finding's flow visualizer, then the triage flow" width="900"/></p>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ 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.
|
||||
|
||||
<p align="center"><img src="assets/screenshots/overview.png" alt="Overview dashboard after two scans: 2 findings remaining (down from 5), 3 fixed, a findings-over-time line trending down, plus severity/language/category breakdowns and top affected files" width="900"/></p>
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -43,7 +44,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 [`docs/serve.md`](docs/serve.md) 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://elicpeter.github.io/nyx/serve.html) for the page-by-page UI tour and security model.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -68,7 +69,7 @@ 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 [`docs/cli.md`](docs/cli.md#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://elicpeter.github.io/nyx/cli.html#engine-depth-profile) for the full toggle matrix.
|
||||
|
||||
### GitHub Action
|
||||
|
||||
|
|
@ -114,16 +115,15 @@ 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 is uneven. Tiers reflect benchmark F1 on the 305-case corpus at [`tests/benchmark/ground_truth.json`](tests/benchmark/ground_truth.json):
|
||||
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 433-case corpus at [`tests/benchmark/ground_truth.json`](tests/benchmark/ground_truth.json) is 100% for nine of ten languages and 94.1% for Go, 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? |
|
||||
|---|---|---|---|
|
||||
| **Stable** | Python, JavaScript, TypeScript | 96.8% to 100% | Yes |
|
||||
| **Beta** | Go, Java, Ruby, PHP | 92.9% to 97.0% | Yes, with light FP triage |
|
||||
| **Preview** | C, C++ | 88.9% to 92.3% | No. Pair with clang-tidy or Clang Static Analyzer |
|
||||
| **Experimental** | Rust | 86.4% | Review findings, don't block merges |
|
||||
| **Stable** | Python, JavaScript, TypeScript | 100% | Yes |
|
||||
| **Beta** | Java, PHP, Ruby, Rust, Go | 94.1% to 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 |
|
||||
|
||||
Per-dimension detail and known blind spots live in [`docs/language-maturity.md`](docs/language-maturity.md).
|
||||
Aggregate rule-level F1: 99.3% (P=0.991, R=0.995). The single open FN is `cve-go-2023-3188-vulnerable` (owncast SSRF); the two open FPs (`go-safe-007`, `go-safe-009`) also sit on the Go side. Per-dimension detail and known blind spots live on the [Language maturity page](https://elicpeter.github.io/nyx/language-maturity.html).
|
||||
|
||||
### Validated against real CVEs
|
||||
|
||||
|
|
@ -134,17 +134,22 @@ The corpus also holds a small set of vulnerable/patched pairs extracted from pub
|
|||
| [CVE-2023-48022](https://nvd.nist.gov/vuln/detail/CVE-2023-48022) | Ray | Python | Command injection |
|
||||
| [CVE-2017-18342](https://nvd.nist.gov/vuln/detail/CVE-2017-18342) | PyYAML | Python | Deserialization |
|
||||
| [CVE-2019-14939](https://nvd.nist.gov/vuln/detail/CVE-2019-14939) | mongo-express | JavaScript | Code execution (`eval`) |
|
||||
| [CVE-2025-64430](https://nvd.nist.gov/vuln/detail/CVE-2025-64430) | Parse Server | JavaScript | SSRF |
|
||||
| [CVE-2023-26159](https://nvd.nist.gov/vuln/detail/CVE-2023-26159) | follow-redirects | TypeScript | SSRF |
|
||||
| [CVE-2022-30323](https://nvd.nist.gov/vuln/detail/CVE-2022-30323) | hashicorp/go-getter | Go | Command injection |
|
||||
| [CVE-2024-31450](https://nvd.nist.gov/vuln/detail/CVE-2024-31450) | owncast | Go | Path traversal |
|
||||
| [CVE-2015-7501](https://nvd.nist.gov/vuln/detail/CVE-2015-7501) | Apache Commons Collections | Java | Deserialization |
|
||||
| [CVE-2017-12629](https://nvd.nist.gov/vuln/detail/CVE-2017-12629) | Apache Solr | Java | Command injection |
|
||||
| [CVE-2013-0156](https://nvd.nist.gov/vuln/detail/CVE-2013-0156) | Ruby on Rails | Ruby | Deserialization |
|
||||
| [CVE-2020-8130](https://nvd.nist.gov/vuln/detail/CVE-2020-8130) | Rake | Ruby | Command injection |
|
||||
| [CVE-2017-9841](https://nvd.nist.gov/vuln/detail/CVE-2017-9841) | PHPUnit | PHP | Code execution (`eval`) |
|
||||
| [CVE-2018-15133](https://nvd.nist.gov/vuln/detail/CVE-2018-15133) | Laravel | PHP | Deserialization |
|
||||
| [CVE-2016-3714](https://nvd.nist.gov/vuln/detail/CVE-2016-3714) | ImageMagick (ImageTragick) | C | Command injection |
|
||||
| [CVE-2019-18634](https://nvd.nist.gov/vuln/detail/CVE-2019-18634) | sudo (pwfeedback) | C | Memory safety |
|
||||
| [CVE-2019-13132](https://nvd.nist.gov/vuln/detail/CVE-2019-13132) | ZeroMQ libzmq | C++ | Memory safety |
|
||||
| [CVE-2022-1941](https://nvd.nist.gov/vuln/detail/CVE-2022-1941) | Protocol Buffers | C++ | Memory safety |
|
||||
| [CVE-2017-12629](https://nvd.nist.gov/vuln/detail/CVE-2017-12629) | Apache Solr | Java | Command injection |
|
||||
|
||||
`cve-go-2023-3188-vulnerable` (owncast SSRF) ships in the corpus too but is currently a known FN; it will move into the table once the engine fires on it.
|
||||
|
||||
Fixtures live under [`tests/benchmark/cve_corpus/`](tests/benchmark/cve_corpus/) with upstream attribution headers.
|
||||
|
||||
|
|
@ -159,7 +164,7 @@ Two passes over the filesystem, with an optional SQLite index to skip unchanged
|
|||
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.
|
||||
|
||||
Detector families: taint (cross-file source→sink), 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: [`docs/detectors.md`](docs/detectors.md).
|
||||
Detector families: taint (cross-file source→sink), 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).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -184,13 +189,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`, `code_exec`, `crypto`, `unauthorized_id`, `all`. Full schema: [`docs/configuration.md`](docs/configuration.md).
|
||||
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`, `code_exec`, `crypto`, `unauthorized_id`, `all`. Full schema: [Configuration](https://elicpeter.github.io/nyx/configuration.html).
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
Under active development. APIs, detector behavior, and configuration options may change between releases. Rule-level F1 on the 305-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 433-case 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`.
|
||||
|
||||
|
|
@ -198,17 +203,19 @@ Limitations:
|
|||
- Interprocedural precision is bounded rather than unlimited. Context-sensitive inlining is k=1 with a callee body-size cap, and SCC fixed-point has an iteration cap. When the engine hits a bound it falls back to summaries and records an `engine_note` on the finding.
|
||||
- Cross-language calls (FFI, subprocess, WASM) are not traversed. Each language is analysed independently.
|
||||
- Several language features are not modeled: macros, most dynamic dispatch, aliased imports, reflection.
|
||||
- Rust is experimental tier; C/C++ are preview tier. Pair them with a clang-based tool before using as a hard CI gate.
|
||||
- C/C++ are preview tier. STL container flow, builder chains, and inline class member functions are tracked now; deep pointer aliasing and function pointers are not. A clean report should not be read as a clean audit. Pair with a clang-based tool before using as a hard CI gate.
|
||||
- Results may contain false positives or false negatives; manual review is expected.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Quick Start](docs/quickstart.md) · [CLI Reference](docs/cli.md) · [Installation](docs/installation.md)
|
||||
- [`nyx serve`](docs/serve.md) · [Output Formats](docs/output.md) · [Configuration](docs/configuration.md)
|
||||
- [How it works](docs/how-it-works.md) · [Detectors](docs/detectors.md) ([Taint](docs/detectors/taint.md), [CFG](docs/detectors/cfg.md), [State](docs/detectors/state.md), [AST Patterns](docs/detectors/patterns.md))
|
||||
- [Rule Reference](docs/rules.md) · [Language Maturity](docs/language-maturity.md) · [Advanced Analysis](docs/advanced-analysis.md) · [Auth Analysis](docs/auth.md)
|
||||
Browse the full docs site at **[elicpeter.github.io/nyx](https://elicpeter.github.io/nyx/)**.
|
||||
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,18 @@
|
|||
|
||||
Nyx today is a static-only multi-language vulnerability scanner. The roadmap below extends it into a hybrid scanner that combines static analysis with controlled execution and AI-assisted reasoning.
|
||||
|
||||
## Phase 1 — Static Analysis (current)
|
||||
## Phase 1: Static Analysis (current)
|
||||
|
||||
The shipped scanner. Multi-language taint tracking on a pruned SSA IR, cross-file function summaries, points-to and abstract interpretation, symbolic execution with an optional SMT backend, and a local web UI for triage. See the [Changelog](CHANGELOG.md) for the full breakdown of what's landed through 0.5.0.
|
||||
|
||||
## Phase 2 — Dynamic Capability
|
||||
## Phase 2: Dynamic Capability
|
||||
|
||||
| Feature | Description |
|
||||
| --- | --- |
|
||||
| Controlled dynamic execution | Local sandbox: identify entry points, spin up test harnesses, inject payloads, detect runtime crashes and command execution. Deterministic automated exploit validation — static finds `exec(user_input)`, dynamic confirms it with `; id`. |
|
||||
| Controlled dynamic execution | Local sandbox: identify entry points, spin up test harnesses, inject payloads, detect runtime crashes and command execution. Deterministic automated exploit validation: static finds `exec(user_input)`, dynamic confirms it with `; id`. |
|
||||
| Fuzzing integration | libFuzzer (C/C++), cargo-fuzz (Rust), go-fuzz, HTTP fuzzing harness. Static engine identifies interesting functions, fuzzer targets only those. |
|
||||
|
||||
## Phase 3 — Intelligent Reasoning Layer
|
||||
## Phase 3: Intelligent Reasoning Layer
|
||||
|
||||
| Feature | Description |
|
||||
| --- | --- |
|
||||
|
|
|
|||
|
|
@ -41,6 +41,6 @@ This policy covers vulnerabilities that let an **untrusted Nyx input** cause:
|
|||
* Remote or local code execution in the Nyx process
|
||||
* Privilege escalation, data exfiltration, or denial of service
|
||||
|
||||
**False positives / missed detections** in scan results are *quality issues*, not security issues—please file normal GitHub issues for those.
|
||||
**False positives / missed detections** in scan results are *quality issues*, not security issues. Please file normal GitHub issues for those.
|
||||
|
||||
[Semantic Versioning]: https://semver.org
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
<h2>Overview of licenses:</h2>
|
||||
<ul class="licenses-overview">
|
||||
<li><a href="#Apache-2.0">Apache License 2.0</a> (159)</li>
|
||||
<li><a href="#MIT">MIT License</a> (69)</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>
|
||||
|
|
@ -5477,6 +5477,36 @@ 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">
|
||||
<h3 id="MIT">MIT License</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/3Hren/msgpack-rust ">rmp-serde 1.3.1</a></li>
|
||||
<li><a href=" https://github.com/3Hren/msgpack-rust ">rmp 0.8.15</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">MIT License
|
||||
|
||||
Copyright (c) 2017 Evgeny Safronov
|
||||
|
||||
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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 444 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 7.1 MiB After Width: | Height: | Size: 16 MiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 407 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 388 KiB After Width: | Height: | Size: 168 KiB |
|
|
@ -156,6 +156,7 @@ fn bench_state_analysis_only(c: &mut Criterion) {
|
|||
&[],
|
||||
&[],
|
||||
&std::collections::HashSet::new(),
|
||||
None,
|
||||
)
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
# Advanced Analysis
|
||||
|
||||
Nyx ships four optional analysis passes that layer on top of the core SSA
|
||||
taint engine. Each pass is independently switchable via config
|
||||
(`[analysis.engine]` in `nyx.conf` / `nyx.local`), a matching CLI flag pair,
|
||||
or; as a legacy last-resort override for library users with no CLI entry
|
||||
point; a `NYX_*` environment variable. All four are **on by default**: turning
|
||||
them off trades precision for speed.
|
||||
Nyx layers several analysis passes on top of the core SSA taint engine.
|
||||
Most are switchable via config (`[analysis.engine]` in `nyx.conf` /
|
||||
`nyx.local`), a matching CLI flag pair, or, as a last-resort override for
|
||||
library users with no CLI entry point, a `NYX_*` environment variable. The
|
||||
five precision-tuning passes (abstract interpretation, context sensitivity,
|
||||
symbolic execution, constraint solving, field-sensitive points-to) are
|
||||
**on by default** because the benchmark numbers in
|
||||
[language-maturity.md](language-maturity.md) are measured with them on.
|
||||
The demand-driven backwards walk and hierarchy fan-out sit alongside but
|
||||
are not user-toggleable in the same way.
|
||||
|
||||
See [`Configuration`](configuration.md#analysisengine) for the full config
|
||||
surface and CLI flag table. This page explains what each pass does, why it
|
||||
|
|
@ -81,6 +85,77 @@ origin-attribution.
|
|||
|
||||
---
|
||||
|
||||
## Field-sensitive points-to
|
||||
|
||||
**What it does.** Runs a Steensgaard-style alias analysis that interns field
|
||||
accesses as their own abstract locations. `c.mu` becomes `Field(c, mu)`,
|
||||
distinct from `c` itself; a write to `obj.cache` and a read from
|
||||
`obj.cache` in different methods both land on the same abstract location;
|
||||
subscript reads and writes (`arr[i]`, `map[k] = v`) lower to synthetic
|
||||
`__index_get__` / `__index_set__` calls so the engine can model them
|
||||
through the same container store/load primitives used for STL containers,
|
||||
Python lists, JS arrays, and similar.
|
||||
|
||||
**Why it helps.** It splits a class of false positives that the
|
||||
whole-variable taint model produced. Before this pass, `obj.field =
|
||||
tainted; sink(obj.other_field)` would taint `obj` as a whole and fire on
|
||||
the safe field; the receiver-type / sub-field distinction is also what
|
||||
lets the resource-lifecycle pass attribute a `c.mu.Lock()` to the lock
|
||||
field rather than to its container. Cross-method field flow (writer in
|
||||
one method, reader in another) shows up only when fields have stable
|
||||
identity independent of the parent value.
|
||||
|
||||
**How to turn it off.**
|
||||
|
||||
| Surface | Value |
|
||||
|---|---|
|
||||
| Env var | `NYX_POINTER_ANALYSIS=0` |
|
||||
|
||||
The pass is **on by default** as of 2026-04-26. The env-var override is
|
||||
kept for one release so you can compare against the pre-pointer baseline,
|
||||
then will be removed.
|
||||
|
||||
**Limitations.** This is not a general escape analysis. Function pointers
|
||||
and arbitrary indirect calls still resolve to no callee, and deep alias
|
||||
chains through `*p` / `p->field` in C/C++ are not tracked beyond the
|
||||
direct field case. The points-to set per value is capped at
|
||||
`--max-pointsto` (default 32); when truncation happens, an engine note
|
||||
records the precision loss.
|
||||
|
||||
**Source**: [`src/pointer/`](https://github.com/elicpeter/nyx/tree/master/src/pointer/).
|
||||
|
||||
---
|
||||
|
||||
## Hierarchy fan-out for virtual dispatch
|
||||
|
||||
**What it does.** Builds a per-language type-hierarchy index in pass 1
|
||||
(extends, implements, impl-for, includes; the exact construct depends on
|
||||
the language) and uses it in pass 2 to widen method-call resolution. When
|
||||
a call's receiver is statically typed as a super-class, trait, or
|
||||
interface, the resolver returns every concrete implementer it has seen
|
||||
in the codebase rather than just the first match.
|
||||
|
||||
**Why it helps.** Without it, a call like `repository.findById(id)` where
|
||||
`repository` is typed as the interface gets resolved against whatever the
|
||||
single-result resolver finds first; if the matching implementer is in
|
||||
another file the call effectively goes opaque. With the hierarchy, the
|
||||
taint engine sees the union of every implementer's transform and the
|
||||
flow shows up regardless of which file holds the concrete class.
|
||||
|
||||
**Limitations.** Fan-out is capped at 8 implementers per call site; over
|
||||
that, the tail is silently dropped (a debug log records the cap hit) and
|
||||
the call is treated as a non-deterministic union of the kept
|
||||
implementers. Languages that use structural / implicit interface
|
||||
satisfaction (Go) are deliberately skipped because per-file extraction
|
||||
is intractable; those calls fall back to the single-result resolver. The
|
||||
extractor covers Java, Rust, TS/JS/TSX, Python, Ruby, PHP, and C++.
|
||||
|
||||
**Source**: [`src/cfg/hierarchy.rs`](https://github.com/elicpeter/nyx/blob/master/src/cfg/hierarchy.rs)
|
||||
and [`src/summary/mod.rs`](https://github.com/elicpeter/nyx/blob/master/src/summary/mod.rs)
|
||||
(`TypeHierarchyIndex`, `resolve_callee_widened`).
|
||||
|
||||
---
|
||||
|
||||
## Symbolic execution
|
||||
|
||||
**What it does.** Builds a symbolic expression tree per tainted SSA value,
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ One rule ID, parameterized by the source location. Suppressions can target eithe
|
|||
## What it can't detect
|
||||
|
||||
- **Library calls without summaries.** If a callee has no summary (no source, binary-only dependency), Nyx treats it as neither propagating nor sanitizing. This is conservative for sanitization but lossy for propagation.
|
||||
- **Taint through struct fields and containers.** Taint attaches to whole variables. `obj.field = tainted; sink(obj.other_field)` can produce a false positive because `obj` itself is tainted.
|
||||
- **Aliasing.** `let y = &x; sink(*y)` tracks `y` separately from `x`. Can cause FNs.
|
||||
- **Deep pointer aliasing.** `let y = &x; sink(*y)` works through one level, but arbitrary chains of pointer arithmetic and aliased writes (`*p`, `p->field` in C/C++) are not tracked end-to-end. Function pointers and indirect calls resolve to no callee.
|
||||
- **Implicit flows.** Taint follows explicit data, not branching signal. `if (secret) x = 1 else x = 0` does not taint `x`.
|
||||
- **Globals and statics across functions.** Not tracked across function boundaries.
|
||||
|
||||
|
|
@ -35,7 +34,7 @@ One rule ID, parameterized by the source location. Suppressions can target eithe
|
|||
| Scenario | Why | Mitigation |
|
||||
|---|---|---|
|
||||
| Custom sanitizer not recognised | Only built-in + configured sanitizers match | Add a custom sanitizer rule in config |
|
||||
| Taint through struct fields | Variable-level tracking, not field-level | No fix yet; field-sensitivity is planned |
|
||||
| Container holds mixed-typed items the engine cannot tell apart | A `vector<int>` of port numbers and a `vector<string>` of user input share the same store/load model | Sanitize the values on the way in (numeric parse / explicit validator) so the values themselves carry no cap, not just the container |
|
||||
| Dead branches | Path-insensitive within a function | Constraint solving catches trivially infeasible combos; path-validated findings are scored lower |
|
||||
| Library wrapper re-introduces taint | Wrapper opaque, or summary marks it as propagating | Summarize the wrapper explicitly or add it as a sanitizer |
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ A scan runs in two passes over the file tree, with an optional SQLite index that
|
|||
|
||||
Two extra layers tune precision around calls. **Context-sensitive inlining** (k=1) re-runs intra-file callees with the actual argument taint at the call site, so a helper called once with tainted input and once with sanitized input produces the right result for each call. **SCC fixed-point**: when a group of mutually-recursive functions forms a strongly-connected component in the call graph, the engine iterates summaries to a joint fixed-point (capped at 64 iterations). SCCs that span files are also handled.
|
||||
|
||||
When a method call has a receiver typed as a super-class, trait, or interface, **hierarchy fan-out** widens the resolved callee set to every concrete implementer the engine has seen. A class diagram extracted in pass 1 (Java extends/implements, Rust impl-for, TS/JS extends, Python bases, Ruby includes, PHP extends/implements, C++ inheritance) feeds an index that the call resolver consults during pass 2. The fan-out is capped at 8 implementers per call site; over-fanning is a precision tax, not a soundness issue.
|
||||
|
||||
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 one release if you need to compare baselines.
|
||||
|
||||
## 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.
|
||||
|
|
@ -22,6 +26,8 @@ These run on top of the forward taint pass. They're independently switchable via
|
|||
|---|---|---|
|
||||
| Abstract interpretation | Carries interval and string prefix/suffix bounds alongside taint. Suppresses findings on proven-bounded integers and locked-prefix URLs | on |
|
||||
| Context sensitivity | k=1 inlining for intra-file callees | on |
|
||||
| Field-sensitive points-to | Distinguishes `obj.field` from `obj` itself, so a tainted write to one field does not poison reads from another. Also gives the resource-lifecycle pass per-field locks | on |
|
||||
| Hierarchy fan-out | When a method call's receiver is typed as a super-class, trait, or interface, widens callee resolution to every concrete implementer the engine has seen | on |
|
||||
| Constraint solving | Drops paths whose accumulated branch predicates are unsatisfiable. Optional Z3 backend with `--features smt` | on |
|
||||
| Symbolic execution | Builds an expression tree per tainted value. Produces a witness string at the sink. Detects sanitization patterns the taint engine alone would miss | on |
|
||||
| Backwards analysis | After the forward pass, walks backwards from each sink to confirm or invalidate the flow. Annotates findings as `backwards-confirmed`, `backwards-infeasible`, or `backwards-budget-exhausted` | off |
|
||||
|
|
|
|||
|
|
@ -9,28 +9,34 @@ 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 305-case
|
||||
corpus (267 synthetic + 14 real-CVE pairs + 10 auth fixtures) in
|
||||
2. **Benchmark results**: rule-level precision / recall / F1 on the 433-case
|
||||
corpus in
|
||||
[`tests/benchmark/RESULTS.md`](https://github.com/elicpeter/nyx/blob/master/tests/benchmark/RESULTS.md),
|
||||
last measured 2026-04-23 with scanner version 0.5.0.
|
||||
last measured 2026-04-29 with scanner version 0.5.0.
|
||||
3. **Known weak spots**: FPs and FNs the maintainers have deliberately left
|
||||
in the benchmark rather than suppressed, documented release-by-release in
|
||||
in the benchmark rather than suppressed, plus structural engine
|
||||
limitations the corpus does not stress, documented release-by-release in
|
||||
[`RESULTS.md`](https://github.com/elicpeter/nyx/blob/master/tests/benchmark/RESULTS.md).
|
||||
|
||||
All parser integrations use tree-sitter and are stable; parsing is not a
|
||||
differentiator between tiers. The differentiators are rule depth, cross-file
|
||||
confidence, and modeled idioms.
|
||||
As of 2026-04-29 the synthetic corpus has effectively saturated: nine of ten
|
||||
languages report rule-level F1 = 100.0% and Go reports 94.1% (two FPs and
|
||||
one FN on a real-CVE SSRF case, `cve-go-2023-3188-vulnerable`). Aggregate
|
||||
rule-level P=0.991, R=0.995, F1=0.993. That means F1 alone no longer
|
||||
differentiates tiers, so the differentiators are **rule depth**,
|
||||
**gated-sink coverage**, and **structural idioms the corpus does not fully
|
||||
stress** (deep pointer aliasing in C/C++, framework-specific context). All
|
||||
parser integrations use tree-sitter and are stable; parsing is not a
|
||||
differentiator.
|
||||
|
||||
---
|
||||
|
||||
## Tier Summary
|
||||
|
||||
| Tier | Languages | What to expect |
|
||||
|------|-----------|----------------|
|
||||
| **Stable** | Python, JavaScript, TypeScript | Deep rule sets, gated sinks (argument-role-aware), framework detection, extensive fixtures, and the bulk of advanced-analysis (SSA, context-sensitivity, symbolic execution) coverage. Safe to depend on in CI gates. |
|
||||
| **Beta** | Go, Java, Ruby, PHP | Solid mid-depth rule sets with known narrower class coverage. No gated sinks yet. Cross-file flows work; some idioms (variable-typed method receivers, framework context, string interpolation) are incomplete. Usable in CI, but review FP/FN lists before tightening gates. |
|
||||
| **Preview** | C, C++ | Pattern-only coverage. Pointer aliasing, function pointers, array-element taint, and STL container flows are not modeled. Suitable for finding obvious unsafe API uses; do not use as a sole SAST gate. Pair with clang-tidy / Clang Static Analyzer / Infer. |
|
||||
| **Experimental** | Rust | Full source coverage relative to the framework ecosystem, but several FPs persist on adversarial safe cases pending engine work (match-arm guards, structural sinks with type facts). Appropriate for spot-checks and contribution but not yet recommended as a sole SAST dependency. |
|
||||
| Tier | Languages | F1 | What to expect |
|
||||
|------|-----------|----|----------------|
|
||||
| **Stable** | Python, JavaScript, TypeScript | 100% | Deep rule sets, gated sinks (argument-role-aware), framework detection, extensive fixtures, and the bulk of advanced-analysis (SSA two-level solve, context-sensitivity, symbolic execution, abstract interpretation) coverage. Safe to depend on in CI gates. |
|
||||
| **Beta** | Go, Java, PHP, Ruby, Rust | 94.1% to 100% | Solid mid-depth rule sets with narrower cap coverage and **no gated sinks**. Cross-file flows work; some idioms (variable-typed method receivers, framework context, string interpolation, match-arm guards) are partially modeled. Usable in CI; review FP/FN lists before tightening gates. |
|
||||
| **Preview** | C, C++ | 100% on synthetic corpus | Recent work taught the engine to follow taint through `std::vector` / `std::string` / map containers (including `c_str()`), through fluent builder chains like `Socket::builder().host(h).connect()`, and through inline class member functions. Function pointers and deeper pointer aliasing through `*p` / `p->field` are still not tracked. Rule-level scores against a corpus of obvious unsafe-API uses look perfect, but that is not the same as a clean audit on a real codebase. Pair with clang-tidy, Clang Static Analyzer, or Infer. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -38,7 +44,7 @@ confidence, and modeled idioms.
|
|||
|
||||
### Stable tier
|
||||
|
||||
#### Python: 100% P / 100% R / 100% F1 *(29-case corpus)*
|
||||
#### Python: 100% P / 100% R / 100% F1 *(46-case corpus)*
|
||||
|
||||
- **Rule depth**: 5 source families, 7 sanitizer families, 21 sink matchers
|
||||
spanning HTML, URL, Shell, SQL, Code, SSRF, File I/O, and Deserialization.
|
||||
|
|
@ -47,52 +53,59 @@ confidence, and modeled idioms.
|
|||
- **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 30 benchmark cases.
|
||||
- **Fixtures**: 125 under `tests/fixtures/` plus 42 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: 93.8% P / 100% R / 96.8% F1 *(27-case corpus)*
|
||||
#### JavaScript: 100% P / 100% R / 100% F1 *(42-case corpus)*
|
||||
|
||||
- **Rule depth**: 3 source families, 10 sanitizer families, 24 sink matchers
|
||||
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 (`analyse_ssa_js_two_level`),
|
||||
prefix-locked SSRF suppression via StringFact.
|
||||
two-level SSA solve for top-level + per-function scopes
|
||||
(`analyse_ssa_js_two_level`), prefix-locked SSRF suppression via
|
||||
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 corpus of any
|
||||
- **Fixtures**: 238 under `tests/fixtures/`; the largest fixture set 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 *(35-case corpus, most recent measurement)*
|
||||
#### TypeScript: 100% P / 100% R / 100% F1 *(47-case corpus)*
|
||||
|
||||
- **Rule depth**: Shares the JS ruleset (3 sources, 10 sanitizers, 24 sinks)
|
||||
plus TS-specific grammar handling.
|
||||
- **Advanced analysis**: TSX and JSX grammars wired as of 2026-04-20;
|
||||
- **Advanced analysis**: TSX and JSX grammars wired;
|
||||
discriminated-union narrowing, generic erasure, decorator flow, and
|
||||
interface dispatch are all validated against adversarial type-system
|
||||
stressors.
|
||||
- **Framework context**: Fastify detection via `detect_in_file_frameworks`
|
||||
(import-driven, no `package.json` required).
|
||||
- **Fixtures**: 39 test fixtures plus 35 benchmark cases.
|
||||
- **Blind spots**: 0 known open weak spots as of 2026-04-20. `as any` casts
|
||||
and `any`-typed flows are handled conservatively (treated as tainted).
|
||||
- **Fixtures**: 39 test fixtures plus 42 benchmark cases.
|
||||
- **Blind spots**: `as any` casts and `any`-typed flows are handled
|
||||
conservatively (treated as tainted).
|
||||
|
||||
### Beta tier
|
||||
|
||||
#### Go: 94.1% P / 100% R / 97.0% F1 *(28-case corpus)*
|
||||
#### Go: 92.3% P / 96.0% R / 94.1% F1 *(53-case corpus, 2 FPs, 1 FN)*
|
||||
|
||||
- **Rule depth**: 4 source families, 4 sanitizer families, 9 sink matchers
|
||||
covering HTML, URL, Shell, SQL, SSRF, Crypto, and File I/O.
|
||||
- **Framework context**: Gin, Echo source matchers.
|
||||
- **Known gaps**: no gated sinks, no deserialization class, allowlist
|
||||
early-return patterns in path-pruning benchmark cases still produce FPs
|
||||
(`go-pathprune-safe-001`). `fmt.Sprintf` is deliberately not a sink.
|
||||
- **Open weak spots**: `cve-go-2023-3188-vulnerable` (owncast SSRF) goes
|
||||
undetected, and two safe Go fixtures (`go-safe-007`, `go-safe-009`) draw
|
||||
spurious SQLi and CMDi findings respectively. These are the only
|
||||
imperfect language scores in the current corpus.
|
||||
- **Known gaps**: no gated sinks, no deserialization class. `fmt.Sprintf`
|
||||
is deliberately not a sink. Cap coverage is narrower than the Stable
|
||||
tier and argument-role-aware sink modeling is not yet implemented for Go,
|
||||
so production CI gates may surface additional FPs the corpus does not
|
||||
exercise.
|
||||
|
||||
#### Java: 92.9% P / 100% R / 96.3% F1 *(23-case corpus)*
|
||||
#### Java: 100% P / 100% R / 100% F1 *(35-case corpus)*
|
||||
|
||||
- **Rule depth**: 3 source families, 8 sanitizer families, 10 sink matchers
|
||||
covering HTML, URL, Shell, SQL, Code, SSRF, and Deserialization.
|
||||
|
|
@ -101,10 +114,19 @@ confidence, and modeled idioms.
|
|||
- **Known gaps**: no gated sinks. Variable-receiver method calls
|
||||
(`client.send(...)` vs `HttpClient.send(...)`) rely on type-qualified
|
||||
resolution from receiver-type inference; flows where the receiver type
|
||||
cannot be inferred are missed (`java-ssrf-002` historically persisted as
|
||||
FN; closed via type facts but fragile on unusual builder chains).
|
||||
cannot be inferred are conservatively over-tainted on unusual builder
|
||||
chains.
|
||||
|
||||
#### Ruby: 100% P / 92.3% R / 96.0% F1 *(24-case corpus)*
|
||||
#### PHP: 100% P / 100% R / 100% F1 *(37-case corpus)*
|
||||
|
||||
- **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.
|
||||
- **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)*
|
||||
|
||||
- **Rule depth**: 3 source families, 7 sanitizer families, 15 sink matchers
|
||||
covering HTML, Shell, SQL, Code, SSRF, File I/O, and Deserialization.
|
||||
|
|
@ -112,154 +134,168 @@ confidence, and modeled idioms.
|
|||
- **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()`). One FN persists on an
|
||||
interprocedural taint propagation case due to rule-ID mismatch, not a
|
||||
missed flow (`rb-interproc-001`).
|
||||
(structurally incompatible with `build_try()`). The previous open
|
||||
`rb-interproc-001` FN closed in the 2026-04-28 baseline after the
|
||||
Ruby `Kernel#open` CMDI sink and exact-match sigil work landed.
|
||||
|
||||
#### PHP: 86.7% P / 100% R / 92.9% F1 *(24-case corpus)*
|
||||
#### Rust: 100% P / 100% R / 100% F1 *(70-case adversarial corpus)*
|
||||
|
||||
- **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.
|
||||
- **Known gaps**: no gated sinks. Limited framework context (Laravel raw
|
||||
methods only). Interprocedural sanitizer-wrapping case
|
||||
(`php-interproc-safe-001`) persists as FP. `echo` language-construct
|
||||
detection is wired but its inner-argument propagation is narrower than
|
||||
function-call sinks.
|
||||
|
||||
### Preview tier
|
||||
|
||||
C and C++ are labeled **Preview** (not Experimental) to convey a specific
|
||||
shape of limitation: the parser and existing rules produce useful findings
|
||||
on obvious unsafe-API uses, but the engine **structurally cannot model**
|
||||
several pervasive C/C++ constructs. Running Nyx on a C/C++ codebase and
|
||||
seeing a clean report should not be read as a clean audit. Pair Nyx with
|
||||
clang-tidy, the Clang Static Analyzer, or Infer for production use.
|
||||
|
||||
**Not modeled** (common to both C and C++):
|
||||
|
||||
- Pointer aliasing. Taint through `*p`, `p->field`, arbitrary pointer
|
||||
arithmetic, and aliased writes are not tracked.
|
||||
- Function pointers and callback dispatch. Indirect calls through
|
||||
`void (*fn)(char *)` resolve to no callee.
|
||||
- Array-element taint. Writes to `buf[i]` do not propagate taint to `buf`
|
||||
in the general case; structural taint chains involving `fgets` → array →
|
||||
`system` have rule-ID matching issues (`c-cmdi-004`).
|
||||
- STL container operations (C++ only). `std::vector`, `std::map`,
|
||||
`std::string` methods are not taint-aware; `c_str()` breaks taint chains
|
||||
(`cpp-cmdi-003`).
|
||||
- Lambdas and nested classes (C++ only). Not modeled.
|
||||
- Complex socket setup (C++ only). E.g. `connect()` chains are not detected
|
||||
(`cpp-ssrf-002`).
|
||||
|
||||
#### C: 85.7% P / 100% R / 92.3% F1 *(20-case corpus)*
|
||||
|
||||
- **Rule depth**: 3 source families, **2** sanitizer families (prefix-based
|
||||
only), 5 sink matchers spanning Shell, File, SSRF, and Format-String.
|
||||
- **Known gaps**: no framework rules, no gated sinks. Path-validation via
|
||||
`strstr()` is not recognized as a guard (`c-safe-006`). Forward-declared
|
||||
sanitizers are not tracked (`c-safe-008`).
|
||||
|
||||
#### C++: 80.0% P / 100% R / 88.9% F1 *(20-case corpus)*
|
||||
|
||||
- **Rule depth**: Clones the C ruleset (3 sources, 2 sanitizers, 5 sinks) and
|
||||
adds `std::cin` / `std::getline` sources.
|
||||
- **Known gaps**: same sanitizer-recognition gaps as C. See the "Not
|
||||
modeled" list above for structural gaps (STL containers, `c_str()`,
|
||||
`connect()`, lambdas, nested classes).
|
||||
|
||||
### Experimental tier
|
||||
|
||||
#### Rust: 76.0% P / 100% R / 86.4% F1 *(31-case adversarial corpus)*
|
||||
Rust holds the largest per-language adversarial corpus and was promoted
|
||||
from Experimental to Beta in the 2026-04-25 measurement after the PathFact
|
||||
landings closed every previously-open `rs-safe-*` regression.
|
||||
|
||||
- **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.
|
||||
- **Recent additions (2026-04-20)**: new SQL class (`rusqlite`, `sqlx`,
|
||||
`diesel`, `postgres`), new Deserialization class (`serde_yaml`,
|
||||
`bincode`, `rmp_serde`, `ciborium`, `ron`, `toml`), expanded file I/O
|
||||
(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.
|
||||
- **Recent additions**: SQL class (`rusqlite`, `sqlx`, `diesel`,
|
||||
`postgres`), Deserialization class (`serde_yaml`, `bincode`,
|
||||
`rmp_serde`, `ciborium`, `ron`, `toml`), expanded file I/O
|
||||
(`fs::remove_file/dir/rename/copy`), `reqwest` SSRF builder chain.
|
||||
- **Known gaps**:
|
||||
- `rs-safe-003`: structural `cfg-unguarded-sink` fires when a tainted
|
||||
variable is *declared* in scope but not used in the sink; intentional
|
||||
for high-risk sinks.
|
||||
- `rs-safe-009`: match-arm guards don't surface as `StmtKind::If`, so
|
||||
`classify_condition` never sees the character-class validation.
|
||||
- `safe_direct_sanitizer.rs`: still FP because the SSA lowering for
|
||||
an OR-chain rejection (`if a || b || c { return X }`) joins both
|
||||
return paths into a single block, losing the early-return
|
||||
semantics. Distinct from the merged-return-block defect closed in
|
||||
2026-04-24; tracked separately.
|
||||
- **Closed by the 2026-04-23 PathFact domain**
|
||||
(`src/abstract_interp/path_domain.rs`): `rs-safe-007` (`.replace("..",
|
||||
"")` sanitiser), `rs-safe-008` (negative-validation return pattern),
|
||||
`rs-safe-010` (static-map lookup; still handled by the dedicated
|
||||
static-map analysis, but PathFact does not interfere), new `rs-safe-012`
|
||||
(`.contains("..")` + `.starts_with('/')` intraprocedural rejection),
|
||||
new `rs-safe-015` (`Path::new(p).is_absolute()` typed rejection), plus a
|
||||
new `rs-path-006` negative-guard to prevent over-suppression.
|
||||
- **Closed by the 2026-04-24 per-return-path PathFact landing**
|
||||
(`PathFactReturnEntry` on `SsaFuncSummary` + structural
|
||||
variant-wrapper transparency + non-data-return skipping +
|
||||
path-fact-proven leaf detection in
|
||||
`trace_tainted_leaf_values`):
|
||||
`rs-safe-014` (Option-returning user sanitiser),
|
||||
new `rs-safe-016` (cross-function `.contains("..")` rejection),
|
||||
`CVE-2018-20997` patched (tar-rs zip-slip),
|
||||
`CVE-2022-36113` patched (cargo `.cargo-ok` symlink),
|
||||
`CVE-2024-24576` patched (BatBadBut argv injection).
|
||||
- **Closed by recent PathFact landings**
|
||||
(`src/abstract_interp/path_domain.rs` + per-return-path PathFact entries
|
||||
on `SsaFuncSummary`): `rs-safe-007` (`.replace("..","")` sanitiser),
|
||||
`rs-safe-008` (negative-validation return), `rs-safe-009` (match-arm
|
||||
guards via condition lifting), `rs-safe-010` (static-map lookup),
|
||||
`rs-safe-012` (`.contains("..")` + `.starts_with('/')` rejection),
|
||||
`rs-safe-014` (Option-returning user sanitiser), `rs-safe-015`
|
||||
(`Path::new(p).is_absolute()` typed rejection), `rs-safe-016`
|
||||
(cross-function `.contains("..")` rejection), and CVE patches
|
||||
`CVE-2018-20997`, `CVE-2022-36113`, `CVE-2024-24576`.
|
||||
- **Not yet covered**: unsafe FFI / `std::mem::transmute` (no rules), Tokio
|
||||
`process::Command` async variants (not distinguished from sync),
|
||||
`hyper` / `surf` / `ureq` SSRF clients (reqwest family only), and Rocket /
|
||||
Actix positive cases (rules exist but no benchmark fixtures yet).
|
||||
`hyper` / `surf` / `ureq` SSRF clients (reqwest family only).
|
||||
|
||||
### Preview tier
|
||||
|
||||
C and C++ remain **Preview** despite reporting 100% rule-level F1 on the
|
||||
synthetic corpus. A run of additions in late April taught the engine to
|
||||
follow taint through several constructs that used to be hard cutoffs (STL
|
||||
containers, builder chains, inline member functions, the wider `std::sto*`
|
||||
family), so the gap between "passes the synthetic corpus" and "would catch
|
||||
the same flow on a real codebase" is narrower than it used to be. It is not
|
||||
zero. The biggest remaining gaps are deep pointer aliasing and function
|
||||
pointers, both of which are pervasive in real C/C++ code. Treat a clean
|
||||
report as a starting point, not an audit. Pair Nyx with clang-tidy, the
|
||||
Clang Static Analyzer, or Infer for production use.
|
||||
|
||||
**What now works** (added in late April):
|
||||
|
||||
- STL container flow. `vec.push_back(tainted)` followed by
|
||||
`vec.front().c_str()` carries taint into a downstream `system()` sink.
|
||||
`std::map::insert_or_assign`, `find`, `count`, `at`, and `data` all
|
||||
participate in the container store/load model.
|
||||
- Inline class member functions. `class C { void run(...) { ... } };`
|
||||
bodies are now extracted as their own functions, so an intra-file call
|
||||
like `inner.run(input)` resolves to the body summary. Same fix covers
|
||||
`struct_specifier`, `union_specifier`, `enum_specifier`,
|
||||
`template_declaration`, and `extern "C"` blocks.
|
||||
- Lambda passthrough. `auto echo = [](const char* s) { return s; };` carries
|
||||
argument taint into the result via the engine's default call-argument
|
||||
propagation.
|
||||
- Builder chains. `Socket::builder().host(user).port(8080).connect()`
|
||||
resolves the chained returns and fires on `.connect()` when `user` is
|
||||
tainted; the safe variant with a hardcoded host stays quiet.
|
||||
- Wider numeric sanitizer family. The full `std::sto*` set (including
|
||||
`stoll`, `stoull`, `stold`) and the C-stdlib forms (`atoi`, `atof`,
|
||||
`strtol`, etc.) clear all caps when they're called.
|
||||
- More header / source extensions. `.cc`, `.cxx`, `.hpp`, `.hxx`, `.hh`,
|
||||
and `.h++` are recognized as C++ on top of `.cpp` and `.c++`. `.h` is
|
||||
intentionally still routed to C since it's ambiguous without a build
|
||||
system.
|
||||
|
||||
**Still not modeled** (common to both C and C++):
|
||||
|
||||
- Deep pointer aliasing. Taint through `*p`, `p->field`, and arbitrary
|
||||
pointer arithmetic is not tracked through arbitrary aliased writes.
|
||||
Field-sensitive points-to (see [Advanced analysis](advanced-analysis.md))
|
||||
handles the "lock on a sub-field" case but is not a general escape
|
||||
analysis.
|
||||
- Function pointers and callback dispatch. An indirect call through
|
||||
`void (*fn)(char *)` resolves to no callee, so cross-pointer flows are
|
||||
invisible.
|
||||
- Array-element taint by index. Writes to `buf[i]` do not always propagate
|
||||
taint to `buf` as a whole; the recent subscript-handling work helps the
|
||||
general case but 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)*
|
||||
|
||||
- **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.
|
||||
- **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)*
|
||||
|
||||
- **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).
|
||||
- **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
|
||||
pointer-aliasing patterns still produce false negatives.
|
||||
|
||||
---
|
||||
|
||||
## How the tiers were assigned
|
||||
|
||||
Because rule-level F1 has saturated for nine of ten languages, the tier
|
||||
boundaries are drawn primarily on **rule depth** and **engine coverage of
|
||||
real-world idioms** rather than on benchmark scores alone.
|
||||
|
||||
A language lands in **Stable** when all three hold:
|
||||
|
||||
- Rule set covers ≥ 8 vulnerability classes with both source and sink
|
||||
matchers, and at least one class has argument-role-aware gating.
|
||||
matchers, and at least one class has argument-role-aware **gated-sink**
|
||||
modeling (e.g. `setAttribute("href", url)` only flags href-like attrs).
|
||||
- Benchmark F1 ≥ 95% on a corpus of ≥ 25 cases.
|
||||
- Advanced analysis (SSA lowering, context-sensitivity, symbolic-execution)
|
||||
is exercised by fixtures for the language.
|
||||
- Advanced analysis (SSA lowering, context-sensitivity, symbolic execution,
|
||||
abstract interpretation) is exercised by fixtures for the language.
|
||||
|
||||
A language lands in **Beta** when benchmark F1 ≥ 90% but at least one of the
|
||||
Stable criteria fails; usually narrower cap coverage or absence of gated
|
||||
sinks.
|
||||
A language lands in **Beta** when benchmark F1 is in the mid-90s or higher
|
||||
on a meaningful corpus but at least one Stable criterion fails. Typical
|
||||
gaps: absence of gated sinks, or sanitizer rule depth narrow enough that
|
||||
the engine compensates structurally rather than via the ruleset.
|
||||
|
||||
A language lands in **Preview** when the engine structurally cannot model
|
||||
constructs that are pervasive in typical codebases for that language
|
||||
(pointer aliasing, function pointers, array-element taint, STL containers
|
||||
for C/C++). Pattern-only coverage is useful but not sufficient as a sole
|
||||
SAST gate.
|
||||
A language lands in **Preview** when the engine has documented structural
|
||||
blind spots for constructs that are pervasive in typical codebases for that
|
||||
language. For C and C++ that means deep pointer aliasing, function
|
||||
pointers, and array-element taint; STL container flow and builder chains
|
||||
have moved out of the blind-spot list. Synthetic-corpus F1 is not a
|
||||
reliable signal for Preview-tier languages: a clean report can coexist
|
||||
with structural gaps.
|
||||
|
||||
A language lands in **Experimental** when rule depth is clearly narrower
|
||||
(≤ 5 sinks and ≤ 2 sanitizers), or benchmark F1 < 90%, or documented weak
|
||||
spots require engine changes rather than rule additions to close, but the
|
||||
engine does not have the pervasive structural blind spots of the Preview
|
||||
tier.
|
||||
(The previous **Experimental** tier was retired in the 2026-04-25
|
||||
measurement when Rust's adversarial corpus reached 100% F1; no language
|
||||
currently sits in that tier.)
|
||||
|
||||
---
|
||||
|
||||
## What this means for you
|
||||
|
||||
- **CI gates**: safe to set strict `--fail-on HIGH` gates on Stable-tier
|
||||
languages. On Beta-tier, expect occasional FP triage; the weak-spot lists
|
||||
above tell you exactly what to skim for. On Preview- and Experimental-tier,
|
||||
treat Nyx findings as a starting point for manual review rather than
|
||||
authoritative; Preview-tier languages in particular have structural
|
||||
blind spots that a clean report will not disclose.
|
||||
languages. On Beta-tier, expect occasional FP triage on production code
|
||||
(the synthetic corpus does not cover every framework idiom); the
|
||||
weak-spot lists above tell you what to skim for. On Preview-tier, treat
|
||||
Nyx findings as a starting point for manual review rather than
|
||||
authoritative. STL container flow and builder chains are tracked now,
|
||||
but deep pointer aliasing and function pointers are not, so a clean
|
||||
report does not tell you what the engine could not see.
|
||||
- **Rule contributions**: the shortest path to raising a language's tier is
|
||||
contributing sink matchers and gated-sink registrations. Label files live
|
||||
at `src/labels/<lang>.rs`; benchmark cases live at
|
||||
`tests/benchmark/corpus/<lang>/`.
|
||||
- **Scope planning**: if your primary stack is C, C++, or Rust, Nyx will
|
||||
surface real findings, but you should budget for review time and consider
|
||||
combining Nyx with a language-specific tool (e.g. `cargo-audit`,
|
||||
`clang-tidy`) until those tiers mature.
|
||||
- **Scope planning**: if your primary stack is C or C++, Nyx will surface
|
||||
real findings on obvious unsafe-API uses, but budget for review time and
|
||||
combine Nyx with `clang-tidy` or the Clang Static Analyzer. Rust is now
|
||||
Beta-tier and suitable as a CI gate; pair with `cargo-audit` for
|
||||
dependency CVEs.
|
||||
|
||||
The benchmark thresholds in `tests/benchmark_test.rs` are deliberately set
|
||||
~5 pp below current baselines so any drop in a language's F1 fails CI. Tier
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ First run builds a SQLite index under `.nyx/`; later runs skip files whose conte
|
|||
|
||||
## What a finding looks like
|
||||
|
||||
<p align="center"><img src="../assets/screenshots/docs/cli-scan-quickstart.png" alt="nyx scan output: two HIGH taint flows (Python os.system, JavaScript document.write) framed by the brand purple gradient" width="900"/></p>
|
||||
<p align="center"><img src="../assets/screenshots/cli-scan.png" alt="nyx scan output: HIGH taint flows from req.params.user, req.query.url, and req.query.path into exec/fetch/fs.readFileSync, framed by the brand purple gradient" width="900"/></p>
|
||||
|
||||
The same scan in console form:
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ The same scan in console form:
|
|||
Sink: os.system
|
||||
|
||||
6:5 ✖ [HIGH] py.cmdi.os_system (Score: 64, Confidence: High)
|
||||
Os.system() — shell command execution
|
||||
os.system() runs a shell command
|
||||
|
||||
/tmp/demo/xss_document_write.js
|
||||
5:5 ✖ [HIGH] taint-unsanitised-flow (source 3:18) (Score: 81, Confidence: High)
|
||||
|
|
@ -33,7 +33,7 @@ The same scan in console form:
|
|||
Sink: document.write
|
||||
|
||||
5:5 ⚠ [MEDIUM] js.xss.document_write (Score: 34, Confidence: High)
|
||||
Document.write() — XSS sink
|
||||
document.write() is an XSS sink
|
||||
|
||||
warning 'demo' generated 10 issues.
|
||||
Finished in 0.054s.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,44 @@ If you forward the port over SSH or expose it through a reverse proxy, the host-
|
|||
|
||||
The numeric `:id` for finding URLs is the position index in the current scan, not a stable fingerprint. Bookmarks across scans aren't reliable; rely on file path + line.
|
||||
|
||||
### Overview and Health Score
|
||||
|
||||
The overview is the landing page after a scan. Severity counts, top affected files, OWASP coverage, and a 0 to 100 Health Score with a letter grade.
|
||||
|
||||
#### How the Health Score is calculated
|
||||
|
||||
Two things drive the score. The density of risk in the codebase, and hard guardrails that decide what the grade can mean.
|
||||
|
||||
Each finding contributes weight = `severity_base × confidence_factor × verdict_factor × context_factor`:
|
||||
|
||||
- Severity base: HIGH 10, MEDIUM 3, LOW (security) 0.5
|
||||
- Confidence: High 1.0, Medium 0.6, Low 0.3
|
||||
- Symex verdict: Confirmed 1.2, NotAttempted 1.0, Inconclusive 0.7, Infeasible 0.1
|
||||
- Context: cross-file taint flow 1.15, intra-file flow 1.0, AST-only or no flow 0.75, test path 0.3
|
||||
|
||||
Quality lints (rule IDs containing `.quality.`) skip the per-finding weight and instead apply a saturating drag, capped at 15 points (so 1000 unwrap lints don't grade worse than 300 do). Total weight gets divided by `sqrt(files / 100)`, clamped between 1 and roughly 22, so a 100-file repo and a 50000-file repo see different denominators but a monorepo can't dilute its way out of a real HIGH.
|
||||
|
||||
The result feeds a log curve into a 0 to 100 base, minus the quality drag. Then HIGH guardrails apply, keyed on the *credibility-adjusted* HIGH count rather than the raw count:
|
||||
|
||||
| effective HIGH | ceiling |
|
||||
|---|---|
|
||||
| 0 | 100 |
|
||||
| 1 | 85 |
|
||||
| 2 | 78 |
|
||||
| 3 to 5 | 68 |
|
||||
| 6 to 10 | 58 |
|
||||
| 11+ | 45 |
|
||||
|
||||
A repo with zero effective HIGHs never grades below C 70. That floor is the structural promise that the score isn't an automated F-machine for projects that have lots of LOW noise but no critical issues.
|
||||
|
||||
Modifiers in the ±5 range nudge the result for trend (only after the second scan), triage coverage (only when total findings ≥ 20), reintroduced findings, and stale HIGHs more than 30 days old.
|
||||
|
||||
#### What the score doesn't measure
|
||||
|
||||
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.
|
||||
|
||||
The current ceilings are calibrated for v0.5 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).
|
||||
|
||||
### Findings and Finding detail
|
||||
|
||||
The findings list is filterable by severity, confidence, category, language, rule ID, and triage state.
|
||||
|
|
|
|||
|
|
@ -2,16 +2,24 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { queryClient } from './api/queryClient';
|
||||
import { SSEProvider } from './contexts/SSEContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { Toaster } from './components/ui/Toaster';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SSEProvider>
|
||||
<BrowserRouter>
|
||||
<AppLayout />
|
||||
</BrowserRouter>
|
||||
</SSEProvider>
|
||||
</QueryClientProvider>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<SSEProvider>
|
||||
<BrowserRouter>
|
||||
<AppLayout />
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</SSEProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,57 @@ const CSRF_HEADER = 'X-Nyx-CSRF';
|
|||
let csrfTokenPromise: Promise<string> | null = null;
|
||||
|
||||
export class ApiError extends Error {
|
||||
/**
|
||||
* Stable machine-readable code (matches backend `ApiError`'s `code` field).
|
||||
* Falls back to a synthetic value when the response wasn't structured —
|
||||
* `network` for fetch failures, `http_<status>` for plain-text responses.
|
||||
*/
|
||||
public code: string;
|
||||
public detail?: unknown;
|
||||
|
||||
constructor(
|
||||
public status: number,
|
||||
status: number,
|
||||
message: string,
|
||||
code?: string,
|
||||
detail?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code ?? `http_${status}`;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
public status: number;
|
||||
|
||||
/** True when the failure was a network/abort, not an HTTP response. */
|
||||
isNetwork(): boolean {
|
||||
return this.status === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build an ApiError from a non-OK Response, parsing a JSON error body if present. */
|
||||
async function errorFromResponse(res: Response): Promise<ApiError> {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
error?: unknown;
|
||||
code?: unknown;
|
||||
detail?: unknown;
|
||||
};
|
||||
const msg =
|
||||
typeof parsed.error === 'string' && parsed.error.length > 0
|
||||
? parsed.error
|
||||
: res.statusText || `HTTP ${res.status}`;
|
||||
const code = typeof parsed.code === 'string' ? parsed.code : undefined;
|
||||
return new ApiError(res.status, msg, code, parsed.detail);
|
||||
} catch {
|
||||
// Plain-text body — use as-is.
|
||||
return new ApiError(res.status, text);
|
||||
}
|
||||
}
|
||||
return new ApiError(res.status, res.statusText || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
async function getCsrfToken(): Promise<string> {
|
||||
|
|
@ -17,10 +61,7 @@ async function getCsrfToken(): Promise<string> {
|
|||
csrfTokenPromise = fetch(`${BASE}/session`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
await res.text().catch(() => res.statusText),
|
||||
);
|
||||
throw await errorFromResponse(res);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
|
@ -31,7 +72,7 @@ async function getCsrfToken(): Promise<string> {
|
|||
typeof payload.csrf_token !== 'string' ||
|
||||
payload.csrf_token.length === 0
|
||||
) {
|
||||
throw new ApiError(500, 'Missing CSRF token');
|
||||
throw new ApiError(500, 'Missing CSRF token', 'missing_csrf_token');
|
||||
}
|
||||
|
||||
return payload.csrf_token;
|
||||
|
|
@ -67,14 +108,23 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
|||
if (opts.body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
...rest,
|
||||
headers,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
...rest,
|
||||
headers,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw err;
|
||||
}
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Network request failed';
|
||||
throw new ApiError(0, message, 'network');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new ApiError(res.status, text);
|
||||
throw await errorFromResponse(res);
|
||||
}
|
||||
|
||||
// Handle empty responses
|
||||
|
|
@ -99,6 +149,26 @@ export function apiPost<T>(
|
|||
});
|
||||
}
|
||||
|
||||
export function apiDelete<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>(path, { method: 'DELETE', signal });
|
||||
export function apiPut<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export function apiDelete<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'DELETE',
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
23
frontend/src/api/mutations/baseline.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiDelete, apiPost } from '../client';
|
||||
|
||||
export function usePinBaseline() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (scanId: string) =>
|
||||
apiPost<void>('/overview/baseline', { scan_id: scanId }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['overview'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnpinBaseline() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiDelete<void>('/overview/baseline'),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['overview'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiPost, apiDelete } from '../client';
|
||||
import { apiPost, apiPut, apiDelete } from '../client';
|
||||
import type { LabelEntryView, TerminatorView, ProfileView } from '../types';
|
||||
|
||||
// --- Sources ---
|
||||
|
|
@ -18,6 +18,7 @@ export function useAddSource() {
|
|||
apiPost<LabelEntryView>('/config/sources', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config', 'sources'] });
|
||||
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -25,9 +26,11 @@ export function useAddSource() {
|
|||
export function useDeleteSource() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: AddLabelBody) => apiDelete<void>('/config/sources'),
|
||||
mutationFn: (body: AddLabelBody) =>
|
||||
apiDelete<void>('/config/sources', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config', 'sources'] });
|
||||
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -41,6 +44,7 @@ export function useAddSink() {
|
|||
apiPost<LabelEntryView>('/config/sinks', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config', 'sinks'] });
|
||||
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -48,9 +52,10 @@ export function useAddSink() {
|
|||
export function useDeleteSink() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: AddLabelBody) => apiDelete<void>('/config/sinks'),
|
||||
mutationFn: (body: AddLabelBody) => apiDelete<void>('/config/sinks', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config', 'sinks'] });
|
||||
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -64,6 +69,7 @@ export function useAddSanitizer() {
|
|||
apiPost<LabelEntryView>('/config/sanitizers', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config', 'sanitizers'] });
|
||||
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -71,9 +77,11 @@ export function useAddSanitizer() {
|
|||
export function useDeleteSanitizer() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: AddLabelBody) => apiDelete<void>('/config/sanitizers'),
|
||||
mutationFn: (body: AddLabelBody) =>
|
||||
apiDelete<void>('/config/sanitizers', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config', 'sanitizers'] });
|
||||
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -92,6 +100,7 @@ export function useAddTerminator() {
|
|||
apiPost<TerminatorView>('/config/terminators', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config', 'terminators'] });
|
||||
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -100,9 +109,10 @@ export function useDeleteTerminator() {
|
|||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: AddTerminatorBody) =>
|
||||
apiDelete<void>('/config/terminators'),
|
||||
apiDelete<void>('/config/terminators', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config', 'terminators'] });
|
||||
qc.invalidateQueries({ queryKey: ['rules'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -143,6 +153,21 @@ export function useActivateProfile() {
|
|||
});
|
||||
}
|
||||
|
||||
// --- Raw nyx.local TOML ---
|
||||
|
||||
export function useSaveRawConfig() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (content: string) =>
|
||||
apiPut<{ status: string; path: string; bytes: number }>('/config/raw', {
|
||||
content,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['config'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Triage Sync ---
|
||||
|
||||
export function useToggleTriageSync() {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,19 @@ export function useConfig() {
|
|||
});
|
||||
}
|
||||
|
||||
export interface RawConfigView {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function useRawConfig() {
|
||||
return useQuery({
|
||||
queryKey: ['config', 'raw'],
|
||||
queryFn: ({ signal }) => apiGet<RawConfigView>('/config/raw', signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSources() {
|
||||
return useQuery({
|
||||
queryKey: ['config', 'sources'],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import type {
|
|||
SymexView,
|
||||
CallGraphView,
|
||||
FuncSummaryView,
|
||||
PointerView,
|
||||
TypeFactsView,
|
||||
AuthAnalysisView,
|
||||
} from '../types';
|
||||
|
||||
export function useDebugFunctions(file: string | null) {
|
||||
|
|
@ -109,3 +112,39 @@ export function useDebugSummaries(
|
|||
apiGet<FuncSummaryView[]>(`/debug/summaries?${params}`, signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDebugPointer(file: string | null, fn_name: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['debug', 'pointer', file, fn_name],
|
||||
queryFn: ({ signal }) =>
|
||||
apiGet<PointerView>(
|
||||
`/debug/pointer?file=${encodeURIComponent(file!)}&function=${encodeURIComponent(fn_name!)}`,
|
||||
signal,
|
||||
),
|
||||
enabled: !!file && !!fn_name,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDebugTypeFacts(file: string | null, fn_name: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['debug', 'type-facts', file, fn_name],
|
||||
queryFn: ({ signal }) =>
|
||||
apiGet<TypeFactsView>(
|
||||
`/debug/type-facts?file=${encodeURIComponent(file!)}&function=${encodeURIComponent(fn_name!)}`,
|
||||
signal,
|
||||
),
|
||||
enabled: !!file && !!fn_name,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDebugAuth(file: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['debug', 'auth', file],
|
||||
queryFn: ({ signal }) =>
|
||||
apiGet<AuthAnalysisView>(
|
||||
`/debug/auth?file=${encodeURIComponent(file!)}`,
|
||||
signal,
|
||||
),
|
||||
enabled: !!file,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,6 +228,120 @@ export interface OverviewResponse {
|
|||
noisy_rules: NoisyRule[];
|
||||
recent_scans: ScanSummary[];
|
||||
insights: Insight[];
|
||||
|
||||
// Tier 1
|
||||
health?: HealthScore;
|
||||
posture?: PostureSummary;
|
||||
backlog?: BacklogStats;
|
||||
weighted_top_files?: WeightedFile[];
|
||||
confidence_distribution?: ConfidenceDistribution;
|
||||
|
||||
// Tier 2
|
||||
scanner_quality?: ScannerQuality;
|
||||
issue_categories?: IssueCategoryBucket[];
|
||||
hot_sinks?: HotSink[];
|
||||
owasp_buckets?: OwaspBucket[];
|
||||
cross_file_ratio?: number;
|
||||
|
||||
// Tier 3
|
||||
baseline?: BaselineInfo;
|
||||
language_health?: LanguageHealth[];
|
||||
suppression_hygiene?: SuppressionHygiene;
|
||||
}
|
||||
|
||||
export interface HealthComponent {
|
||||
label: string;
|
||||
score: number;
|
||||
weight: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface HealthScore {
|
||||
score: number;
|
||||
grade: string;
|
||||
components: HealthComponent[];
|
||||
}
|
||||
|
||||
export interface PostureSummary {
|
||||
trend: 'improving' | 'regressing' | 'stable' | 'unknown' | string;
|
||||
severity: 'success' | 'warning' | 'danger' | 'info' | string;
|
||||
message: string;
|
||||
reintroduced_count: number;
|
||||
}
|
||||
|
||||
export interface BacklogStats {
|
||||
oldest_open_days?: number;
|
||||
median_age_days?: number;
|
||||
stale_count: number;
|
||||
age_buckets: OverviewCount[];
|
||||
}
|
||||
|
||||
export interface WeightedFile {
|
||||
name: string;
|
||||
score: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ConfidenceDistribution {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
none: number;
|
||||
}
|
||||
|
||||
export interface ScannerQuality {
|
||||
files_scanned: number;
|
||||
files_skipped: number;
|
||||
parse_success_rate: number;
|
||||
functions_analyzed: number;
|
||||
call_edges: number;
|
||||
unresolved_calls: number;
|
||||
call_resolution_rate: number;
|
||||
symex_verified_rate: number;
|
||||
symex_breakdown: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface IssueCategoryBucket {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HotSink {
|
||||
callee: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface OwaspBucket {
|
||||
code: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface LanguageHealth {
|
||||
language: string;
|
||||
findings: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export interface SuppressionHygiene {
|
||||
fingerprint_level: number;
|
||||
rule_level: number;
|
||||
file_level: number;
|
||||
rule_in_file_level: number;
|
||||
blanket_rate: number;
|
||||
}
|
||||
|
||||
export interface BaselineInfo {
|
||||
scan_id: string;
|
||||
started_at?: string;
|
||||
baseline_total: number;
|
||||
drift_new: number;
|
||||
drift_fixed: number;
|
||||
}
|
||||
|
||||
// Rules types
|
||||
|
|
@ -361,7 +475,15 @@ export interface TreeEntry {
|
|||
|
||||
export interface SymbolEntry {
|
||||
name: string;
|
||||
/// Legacy display kind (`"function"` | `"method"`) used by existing
|
||||
/// CSS classes. Prefer `func_kind` for new logic.
|
||||
kind: string;
|
||||
/// Structural FuncKind slug: `"fn"` | `"method"` | `"closure"` |
|
||||
/// `"ctor"` | `"getter"` | `"setter"` | `"toplevel"`.
|
||||
func_kind: string;
|
||||
/// Enclosing container (class / impl / module / outer function).
|
||||
/// Empty for free top-level functions.
|
||||
container: string;
|
||||
line?: number;
|
||||
finding_count: number;
|
||||
namespace?: string;
|
||||
|
|
@ -393,6 +515,10 @@ export interface ScanLogEntry {
|
|||
export interface FunctionInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
/// Enclosing container (class / impl / module / outer function).
|
||||
container: string;
|
||||
/// Structural FuncKind slug: `"fn"` | `"method"` | `"closure"` | etc.
|
||||
func_kind: string;
|
||||
param_count: number;
|
||||
line: number;
|
||||
source_caps: string[];
|
||||
|
|
@ -580,6 +706,10 @@ export interface FuncSummaryView {
|
|||
file_path: string;
|
||||
lang: string;
|
||||
namespace: string;
|
||||
/// Enclosing container (class / impl / module / outer function).
|
||||
container: string;
|
||||
/// Structural FuncKind slug: `"fn"` | `"method"` | `"closure"` | etc.
|
||||
func_kind: string;
|
||||
arity?: number;
|
||||
param_count: number;
|
||||
source_caps: string[];
|
||||
|
|
@ -591,3 +721,124 @@ export interface FuncSummaryView {
|
|||
callees: string[];
|
||||
ssa_summary?: SsaSummaryView;
|
||||
}
|
||||
|
||||
// ── Pointer (field-sensitive Steensgaard) ─────────────────────────────────
|
||||
export interface PointerLocationView {
|
||||
id: number;
|
||||
kind: 'Top' | 'Alloc' | 'Param' | 'SelfParam' | 'Field';
|
||||
display: string;
|
||||
parent?: number;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export interface PointerValueView {
|
||||
ssa_value: number;
|
||||
var_name?: string;
|
||||
points_to: number[];
|
||||
is_top: boolean;
|
||||
}
|
||||
|
||||
export interface PointerFieldEntryView {
|
||||
/// `null` means the implicit receiver.
|
||||
param_index: number | null;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface PointerView {
|
||||
locations: PointerLocationView[];
|
||||
values: PointerValueView[];
|
||||
field_reads: PointerFieldEntryView[];
|
||||
field_writes: PointerFieldEntryView[];
|
||||
location_count: number;
|
||||
}
|
||||
|
||||
// ── Type Facts (standalone) ────────────────────────────────────────────────
|
||||
export interface DtoFieldView {
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface DtoFactView {
|
||||
class_name: string;
|
||||
fields: DtoFieldView[];
|
||||
}
|
||||
|
||||
export interface TypeFactDetailView {
|
||||
ssa_value: number;
|
||||
var_name?: string;
|
||||
line: number;
|
||||
kind: string;
|
||||
nullable: boolean;
|
||||
container?: string;
|
||||
dto?: DtoFactView;
|
||||
}
|
||||
|
||||
export interface TypeFactsView {
|
||||
facts: TypeFactDetailView[];
|
||||
total_values: number;
|
||||
unknown_count: number;
|
||||
}
|
||||
|
||||
// ── Auth Analysis ──────────────────────────────────────────────────────────
|
||||
export interface AuthValueRefView {
|
||||
source_kind: string;
|
||||
name: string;
|
||||
base?: string;
|
||||
field?: string;
|
||||
index?: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface AuthCheckView {
|
||||
kind: string;
|
||||
callee: string;
|
||||
line: number;
|
||||
subjects: AuthValueRefView[];
|
||||
args: string[];
|
||||
condition_text?: string;
|
||||
}
|
||||
|
||||
export interface AuthOperationView {
|
||||
kind: string;
|
||||
sink_class?: string;
|
||||
callee: string;
|
||||
line: number;
|
||||
text: string;
|
||||
subjects: AuthValueRefView[];
|
||||
}
|
||||
|
||||
export interface AuthCallSiteView {
|
||||
name: string;
|
||||
line: number;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface AuthUnitView {
|
||||
kind: string;
|
||||
name?: string;
|
||||
line: number;
|
||||
params: string[];
|
||||
auth_checks: AuthCheckView[];
|
||||
operations: AuthOperationView[];
|
||||
call_sites: AuthCallSiteView[];
|
||||
self_actor_vars: string[];
|
||||
typed_bounded_vars: string[];
|
||||
authorized_sql_vars: string[];
|
||||
const_bound_vars: string[];
|
||||
}
|
||||
|
||||
export interface AuthRouteView {
|
||||
framework: string;
|
||||
method: string;
|
||||
path: string;
|
||||
middleware: string[];
|
||||
handler_params: string[];
|
||||
line: number;
|
||||
unit_idx: number;
|
||||
}
|
||||
|
||||
export interface AuthAnalysisView {
|
||||
routes: AuthRouteView[];
|
||||
units: AuthUnitView[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,15 +108,6 @@ export function DebugIcon({ className, size = 18 }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function SettingsIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<circle cx="9" cy="9" r="2.5" />
|
||||
<path d="M9 1.5v2M9 14.5v2M1.5 9h2M14.5 9h2M3.7 3.7l1.4 1.4M12.9 12.9l1.4 1.4M14.3 3.7l-1.4 1.4M5.1 12.9l-1.4 1.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderIcon({ className, size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
|
|
@ -154,6 +145,48 @@ export function TagIcon({ className, size = 14 }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function CloseIcon({ className, size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 14 14">
|
||||
<path d="M3 3l8 8M11 3l-8 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon({ className, size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="3" />
|
||||
<path d="M8 1.5v1.5M8 13v1.5M1.5 8h1.5M13 8h1.5M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon({ className, size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 16 16">
|
||||
<path d="M13.5 9.5A6 6 0 0 1 6.5 2.5 6 6 0 1 0 13.5 9.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshIcon({ className, size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 16 16">
|
||||
<path d="M14 8a6 6 0 1 1-1.76-4.24" />
|
||||
<path d="M14 2v4h-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandIcon({ className, size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 16 16">
|
||||
<path d="M5 3a2 2 0 1 0 0 4h6a2 2 0 1 0 0-4 2 2 0 0 0-2 2v6a2 2 0 1 0 2 2 2 2 0 0 0-2-2H5a2 2 0 1 0 0 4 2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map of icon name to component, for dynamic lookup */
|
||||
export const ICONS: Record<string, FC<IconProps>> = {
|
||||
overview: OverviewIcon,
|
||||
|
|
@ -164,7 +197,6 @@ export const ICONS: Record<string, FC<IconProps>> = {
|
|||
config: ConfigIcon,
|
||||
explorer: ExplorerIcon,
|
||||
debug: DebugIcon,
|
||||
settings: SettingsIcon,
|
||||
folder: FolderIcon,
|
||||
tag: TagIcon,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { HeaderBar } from './HeaderBar';
|
||||
import { NewScanModal } from '../../modals/NewScanModal';
|
||||
import { CommandPalette, type PaletteCommand } from '../ui/CommandPalette';
|
||||
import { ShortcutsHelp } from '../ui/ShortcutsHelp';
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||||
import { useChordNavigation } from '../../hooks/useChordNavigation';
|
||||
import { OverviewPage } from '../../pages/OverviewPage';
|
||||
import { FindingsPage } from '../../pages/FindingsPage';
|
||||
import { FindingDetailPage } from '../../pages/FindingDetailPage';
|
||||
|
|
@ -12,7 +16,6 @@ import { ScanComparePage } from '../../pages/ScanComparePage';
|
|||
import { RulesPage } from '../../pages/RulesPage';
|
||||
import { TriagePage } from '../../pages/TriagePage';
|
||||
import { ConfigPage } from '../../pages/ConfigPage';
|
||||
import { StubPage } from '../../pages/StubPage';
|
||||
import { ExplorerPage } from '../../pages/ExplorerPage';
|
||||
import { DebugLayout } from '../../pages/debug/DebugLayout';
|
||||
import { CallGraphPage } from '../../pages/debug/CallGraphPage';
|
||||
|
|
@ -20,16 +23,108 @@ import { SummaryExplorerPage } from '../../pages/debug/SummaryExplorerPage';
|
|||
|
||||
export function AppLayout() {
|
||||
const [scanModalOpen, setScanModalOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const handleStartScan = useCallback(() => {
|
||||
setScanModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const commands = useMemo<PaletteCommand[]>(
|
||||
() => [
|
||||
// Navigation
|
||||
{ id: 'go-overview', group: 'Navigate', label: 'Overview', to: '/' },
|
||||
{
|
||||
id: 'go-findings',
|
||||
group: 'Navigate',
|
||||
label: 'Findings',
|
||||
to: '/findings',
|
||||
},
|
||||
{ id: 'go-scans', group: 'Navigate', label: 'Scans', to: '/scans' },
|
||||
{ id: 'go-rules', group: 'Navigate', label: 'Rules', to: '/rules' },
|
||||
{ id: 'go-triage', group: 'Navigate', label: 'Triage', to: '/triage' },
|
||||
{ id: 'go-config', group: 'Navigate', label: 'Config', to: '/config' },
|
||||
{
|
||||
id: 'go-explorer',
|
||||
group: 'Navigate',
|
||||
label: 'Explorer',
|
||||
to: '/explorer',
|
||||
},
|
||||
{
|
||||
id: 'go-debug-cg',
|
||||
group: 'Navigate',
|
||||
label: 'Call Graph',
|
||||
hint: 'Debug',
|
||||
to: '/debug/call-graph',
|
||||
},
|
||||
{
|
||||
id: 'go-debug-summaries',
|
||||
group: 'Navigate',
|
||||
label: 'Summary Explorer',
|
||||
hint: 'Debug',
|
||||
to: '/debug/summaries',
|
||||
},
|
||||
// Actions
|
||||
{
|
||||
id: 'start-scan',
|
||||
group: 'Actions',
|
||||
label: 'Start new scan',
|
||||
keywords: ['scan', 'run'],
|
||||
action: () => setScanModalOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'show-shortcuts',
|
||||
group: 'Actions',
|
||||
label: 'Show keyboard shortcuts',
|
||||
keywords: ['help', 'keys'],
|
||||
shortcut: '?',
|
||||
action: () => setHelpOpen(true),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
useChordNavigation();
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'k',
|
||||
meta: true,
|
||||
description: 'Open command palette',
|
||||
handler: () => setPaletteOpen(true),
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
key: '?',
|
||||
shift: true,
|
||||
description: 'Show keyboard shortcuts',
|
||||
handler: () => setHelpOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
description: 'Close modal / palette',
|
||||
handler: () => {
|
||||
if (paletteOpen) setPaletteOpen(false);
|
||||
else if (helpOpen) setHelpOpen(false);
|
||||
else if (scanModalOpen) setScanModalOpen(false);
|
||||
},
|
||||
allowInInput: true,
|
||||
},
|
||||
],
|
||||
[paletteOpen, helpOpen, scanModalOpen],
|
||||
);
|
||||
|
||||
useKeyboardShortcuts(shortcuts);
|
||||
|
||||
return (
|
||||
<div id="app">
|
||||
<Sidebar />
|
||||
<div className="main-panel">
|
||||
<HeaderBar onStartScan={handleStartScan} />
|
||||
<HeaderBar
|
||||
onStartScan={handleStartScan}
|
||||
onOpenPalette={() => setPaletteOpen(true)}
|
||||
/>
|
||||
<main className="content">
|
||||
<Routes>
|
||||
<Route path="/" element={<OverviewPage />} />
|
||||
|
|
@ -53,8 +148,11 @@ export function AppLayout() {
|
|||
/>
|
||||
<Route path="call-graph" element={<CallGraphPage />} />
|
||||
<Route path="summaries" element={<SummaryExplorerPage />} />
|
||||
<Route
|
||||
path="auth"
|
||||
element={<Navigate to="/explorer?view=auth" replace />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/settings" element={<StubPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -62,6 +160,12 @@ export function AppLayout() {
|
|||
open={scanModalOpen}
|
||||
onClose={() => setScanModalOpen(false)}
|
||||
/>
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
commands={commands}
|
||||
/>
|
||||
<ShortcutsHelp open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { CommandIcon } from '../icons/Icons';
|
||||
|
||||
const SECTION_TITLES: Record<string, string> = {
|
||||
overview: 'Overview',
|
||||
|
|
@ -9,7 +10,6 @@ const SECTION_TITLES: Record<string, string> = {
|
|||
config: 'Config',
|
||||
explorer: 'Explorer',
|
||||
debug: 'Debug',
|
||||
settings: 'Settings',
|
||||
};
|
||||
|
||||
const ROUTE_TITLES: Record<string, string> = {
|
||||
|
|
@ -17,6 +17,7 @@ const ROUTE_TITLES: Record<string, string> = {
|
|||
'/debug/ssa': 'SSA Viewer',
|
||||
'/debug/call-graph': 'Call Graph',
|
||||
'/debug/taint': 'Taint Debugger',
|
||||
'/debug/summaries': 'Summaries',
|
||||
};
|
||||
|
||||
function pathToSection(pathname: string): string {
|
||||
|
|
@ -30,17 +31,14 @@ function buildBreadcrumbs(pathname: string) {
|
|||
const sectionTitle = SECTION_TITLES[section] ?? section;
|
||||
const crumbs: Array<{ label: string; path?: string }> = [];
|
||||
|
||||
// Always show section as root breadcrumb
|
||||
const sectionPath = section === 'overview' ? '/' : `/${section}`;
|
||||
crumbs.push({ label: sectionTitle, path: sectionPath });
|
||||
|
||||
// If we have a sub-route, show it
|
||||
if (ROUTE_TITLES[pathname]) {
|
||||
crumbs.push({ label: ROUTE_TITLES[pathname] });
|
||||
} else {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
// e.g. /findings/123 or /scans/compare/1/2
|
||||
const sub = parts.slice(1).join('/');
|
||||
crumbs.push({ label: sub });
|
||||
}
|
||||
|
|
@ -51,23 +49,38 @@ function buildBreadcrumbs(pathname: string) {
|
|||
|
||||
interface HeaderBarProps {
|
||||
onStartScan?: () => void;
|
||||
onOpenPalette?: () => void;
|
||||
}
|
||||
|
||||
export function HeaderBar({ onStartScan }: HeaderBarProps) {
|
||||
const PALETTE_HINT =
|
||||
typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform)
|
||||
? '⌘K'
|
||||
: 'Ctrl K';
|
||||
|
||||
export function HeaderBar({ onStartScan, onOpenPalette }: HeaderBarProps) {
|
||||
const { pathname } = useLocation();
|
||||
const crumbs = buildBreadcrumbs(pathname);
|
||||
|
||||
return (
|
||||
<header className="header-bar">
|
||||
<div className="header-left">
|
||||
<nav className="breadcrumbs">
|
||||
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||
{crumbs.map((crumb, i) => {
|
||||
const isLast = i === crumbs.length - 1;
|
||||
return (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="breadcrumb-sep">/</span>}
|
||||
{i > 0 && (
|
||||
<span className="breadcrumb-sep" aria-hidden="true">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{isLast || !crumb.path ? (
|
||||
<span className="breadcrumb-current">{crumb.label}</span>
|
||||
<span
|
||||
className="breadcrumb-current"
|
||||
aria-current={isLast ? 'page' : undefined}
|
||||
>
|
||||
{crumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link to={crumb.path} className="breadcrumb-link">
|
||||
{crumb.label}
|
||||
|
|
@ -79,8 +92,25 @@ export function HeaderBar({ onStartScan }: HeaderBarProps) {
|
|||
</nav>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{onOpenPalette && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm palette-trigger"
|
||||
onClick={onOpenPalette}
|
||||
aria-label="Open command palette"
|
||||
title={`Command palette (${PALETTE_HINT})`}
|
||||
>
|
||||
<CommandIcon size={12} />
|
||||
<span>Search</span>
|
||||
<kbd>{PALETTE_HINT}</kbd>
|
||||
</button>
|
||||
)}
|
||||
{onStartScan && (
|
||||
<button className="btn btn-primary btn-sm" onClick={onStartScan}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={onStartScan}
|
||||
>
|
||||
Start Scan
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
ConfigIcon,
|
||||
ExplorerIcon,
|
||||
DebugIcon,
|
||||
SettingsIcon,
|
||||
FolderIcon,
|
||||
TagIcon,
|
||||
} from '../icons/Icons';
|
||||
|
|
@ -61,13 +60,6 @@ const NAV_SECTIONS: NavItem[] = [
|
|||
Icon: TriageIcon,
|
||||
group: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: 'Config',
|
||||
path: '/config',
|
||||
Icon: ConfigIcon,
|
||||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
label: 'Explorer',
|
||||
|
|
@ -83,10 +75,10 @@ const NAV_SECTIONS: NavItem[] = [
|
|||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
path: '/settings',
|
||||
Icon: SettingsIcon,
|
||||
id: 'config',
|
||||
label: 'Config',
|
||||
path: '/config',
|
||||
Icon: ConfigIcon,
|
||||
group: 'footer',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
615
frontend/src/components/overview/OverviewWidgets.tsx
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import type {
|
||||
HealthScore,
|
||||
PostureSummary,
|
||||
BacklogStats,
|
||||
ConfidenceDistribution,
|
||||
ScannerQuality,
|
||||
HotSink,
|
||||
OwaspBucket,
|
||||
LanguageHealth,
|
||||
SuppressionHygiene,
|
||||
BaselineInfo,
|
||||
WeightedFile,
|
||||
OverviewCount,
|
||||
} from '../../api/types';
|
||||
import { truncPath } from '../../utils/truncPath';
|
||||
|
||||
// ── HealthScoreCard ─────────────────────────────────────────────────────────
|
||||
|
||||
export function HealthScoreCard({
|
||||
health,
|
||||
posture,
|
||||
}: {
|
||||
health: HealthScore;
|
||||
posture?: PostureSummary;
|
||||
}) {
|
||||
const gradeClass = `grade-${health.grade.toLowerCase()}`;
|
||||
return (
|
||||
<div className="card health-card">
|
||||
<div className="health-eyebrow">Health Score</div>
|
||||
<div className="health-headline">
|
||||
<div className={`health-grade-block ${gradeClass}`}>
|
||||
<span className="health-grade-letter">{health.grade}</span>
|
||||
</div>
|
||||
<div className="health-headline-text">
|
||||
<div className="health-summary">
|
||||
<span className="health-number">{health.score}</span>
|
||||
<span className="health-of">/ 100</span>
|
||||
</div>
|
||||
{posture && (
|
||||
<div className={`health-posture posture-${posture.severity}`}>
|
||||
{posture.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="health-components">
|
||||
{health.components.map((c) => (
|
||||
<div className="health-component" key={c.label} title={c.detail}>
|
||||
<div className="health-component-score">{c.score}</div>
|
||||
<div className="health-component-label">{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PostureBanner ──────────────────────────────────────────────────────────
|
||||
|
||||
export function PostureBanner({ posture }: { posture: PostureSummary }) {
|
||||
return (
|
||||
<div className={`posture-banner posture-${posture.severity}`}>
|
||||
<span className="posture-dot" aria-hidden />
|
||||
<span className="posture-message">{posture.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BacklogCard ────────────────────────────────────────────────────────────
|
||||
|
||||
export function BacklogCard({ backlog }: { backlog: BacklogStats }) {
|
||||
const total = backlog.age_buckets.reduce((s, b) => s + b.count, 0);
|
||||
const noHistory =
|
||||
backlog.oldest_open_days == null && backlog.age_buckets.length === 0;
|
||||
if (noHistory) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="card backlog-card">
|
||||
<div className="card-header">Backlog Age</div>
|
||||
<div className="backlog-body">
|
||||
<div className="backlog-stat">
|
||||
<div className="backlog-stat-value">
|
||||
{backlog.oldest_open_days != null
|
||||
? `${backlog.oldest_open_days}d`
|
||||
: '–'}
|
||||
</div>
|
||||
<div className="backlog-stat-label">Oldest open</div>
|
||||
</div>
|
||||
<div className="backlog-stat">
|
||||
<div className="backlog-stat-value">
|
||||
{backlog.median_age_days != null
|
||||
? `${backlog.median_age_days}d`
|
||||
: '–'}
|
||||
</div>
|
||||
<div className="backlog-stat-label">Median age</div>
|
||||
</div>
|
||||
<div className="backlog-stat">
|
||||
<div className="backlog-stat-value">{backlog.stale_count}</div>
|
||||
<div className="backlog-stat-label">Older than 30 days</div>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="backlog-bucket">
|
||||
<BucketBar buckets={backlog.age_buckets} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketBar({ buckets }: { buckets: OverviewCount[] }) {
|
||||
const total = buckets.reduce((s, b) => s + b.count, 0);
|
||||
if (total === 0) return null;
|
||||
const colors = ['#3498db', '#2ecc71', '#f1c40f', '#e67e22', '#e74c3c'];
|
||||
return (
|
||||
<div
|
||||
className="bucket-bar"
|
||||
title={buckets.map((b) => `${b.name}: ${b.count}`).join(' · ')}
|
||||
>
|
||||
{buckets.map((b, i) => (
|
||||
<div
|
||||
key={b.name}
|
||||
className="bucket-segment"
|
||||
style={{
|
||||
width: `${(b.count / total) * 100}%`,
|
||||
background: colors[i] || 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConfidenceDistributionChart ────────────────────────────────────────────
|
||||
|
||||
export function ConfidenceDistributionChart({
|
||||
dist,
|
||||
}: {
|
||||
dist: ConfidenceDistribution;
|
||||
}) {
|
||||
const total = dist.high + dist.medium + dist.low + dist.none;
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>No data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const segments = [
|
||||
{ label: 'High', value: dist.high, color: '#27ae60' },
|
||||
{ label: 'Medium', value: dist.medium, color: '#f39c12' },
|
||||
{ label: 'Low', value: dist.low, color: '#95a5a6' },
|
||||
{ label: 'None', value: dist.none, color: '#bdc3c7' },
|
||||
];
|
||||
return (
|
||||
<div className="confidence-dist">
|
||||
<div className="confidence-bar">
|
||||
{segments.map((s) =>
|
||||
s.value > 0 ? (
|
||||
<div
|
||||
key={s.label}
|
||||
className="confidence-segment"
|
||||
style={{
|
||||
width: `${(s.value / total) * 100}%`,
|
||||
background: s.color,
|
||||
}}
|
||||
title={`${s.label}: ${s.value}`}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div className="confidence-legend">
|
||||
{segments.map((s) => (
|
||||
<div key={s.label} className="confidence-legend-item">
|
||||
<span
|
||||
className="confidence-swatch"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
<span>{s.label}</span>
|
||||
<span className="confidence-count">{s.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ScannerQualityPanel ────────────────────────────────────────────────────
|
||||
|
||||
export function ScannerQualityPanel({
|
||||
quality,
|
||||
crossFileRatio,
|
||||
}: {
|
||||
quality: ScannerQuality;
|
||||
crossFileRatio?: number;
|
||||
}) {
|
||||
const symexAttempted = Object.entries(quality.symex_breakdown || {})
|
||||
.filter(([k]) => k !== 'not_attempted')
|
||||
.reduce((s, [, v]) => s + v, 0);
|
||||
const symexTotal = Object.values(quality.symex_breakdown || {}).reduce(
|
||||
(s, v) => s + v,
|
||||
0,
|
||||
);
|
||||
const totalFiles = quality.files_scanned + quality.files_skipped;
|
||||
const filesValue = totalFiles.toLocaleString();
|
||||
const filesDetail =
|
||||
quality.files_skipped > 0
|
||||
? `${quality.files_scanned.toLocaleString()} fresh · ${quality.files_skipped.toLocaleString()} from cache`
|
||||
: quality.files_scanned > 0
|
||||
? `${quality.files_scanned.toLocaleString()} freshly indexed`
|
||||
: undefined;
|
||||
|
||||
const rows: Array<{
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
}> = [
|
||||
{
|
||||
label: 'Files',
|
||||
hint: 'Files the scanner saw on this run.',
|
||||
value: filesValue,
|
||||
detail: filesDetail,
|
||||
},
|
||||
{
|
||||
label: 'Functions analyzed',
|
||||
hint: 'Function bodies the call graph saw.',
|
||||
value: quality.functions_analyzed.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: 'Call edges resolved',
|
||||
hint: 'Share of call sites that the scanner resolved to a known callee. The remainder are typically external/library calls.',
|
||||
value: `${(quality.call_resolution_rate * 100).toFixed(1)}%`,
|
||||
detail:
|
||||
quality.unresolved_calls > 0
|
||||
? `${quality.unresolved_calls.toLocaleString()} unresolved`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Cross-file flows',
|
||||
hint: 'Findings whose taint path crosses a file boundary.',
|
||||
value:
|
||||
crossFileRatio != null ? `${(crossFileRatio * 100).toFixed(1)}%` : '0%',
|
||||
detail: 'of findings',
|
||||
},
|
||||
{
|
||||
label: 'Symbolic verification',
|
||||
hint: 'Taint findings the symbolic engine attempted to verify (confirmed, infeasible, or inconclusive).',
|
||||
value:
|
||||
symexTotal > 0
|
||||
? `${(quality.symex_verified_rate * 100).toFixed(1)}%`
|
||||
: 'n/a',
|
||||
detail:
|
||||
symexTotal > 0
|
||||
? `${symexAttempted} of ${symexTotal} taint findings`
|
||||
: 'no taint findings',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<dl className="kv-list">
|
||||
{rows.map((r) => (
|
||||
<div className="kv-row" key={r.label}>
|
||||
<dt className="kv-label" title={r.hint}>
|
||||
{r.label}
|
||||
</dt>
|
||||
<dd className="kv-value">
|
||||
<div className="kv-number">{r.value}</div>
|
||||
{r.detail && <div className="kv-detail">{r.detail}</div>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
// ── HotSinksList ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HotSinksList({ sinks }: { sinks: HotSink[] }) {
|
||||
if (!sinks.length) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>No data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sink</th>
|
||||
<th>Findings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sinks.map((s) => (
|
||||
<tr key={s.callee} title={s.callee}>
|
||||
<td className="font-mono">{s.callee}</td>
|
||||
<td>{s.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ── OwaspChart ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function OwaspChart({ buckets }: { buckets: OwaspBucket[] }) {
|
||||
if (!buckets.length) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>No data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const max = Math.max(...buckets.map((b) => b.count), 1);
|
||||
return (
|
||||
<ul className="owasp-list">
|
||||
{buckets.map((b) => (
|
||||
<li key={b.code} className="owasp-row" title={b.label}>
|
||||
<span className="owasp-code">{b.code}</span>
|
||||
<span className="owasp-label">{b.label}</span>
|
||||
<div className="owasp-bar">
|
||||
<div
|
||||
className="owasp-fill"
|
||||
style={{ width: `${(b.count / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="owasp-count">{b.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ── WeightedTopFiles ───────────────────────────────────────────────────────
|
||||
|
||||
export function WeightedTopFiles({
|
||||
files,
|
||||
onRowClick,
|
||||
}: {
|
||||
files: WeightedFile[];
|
||||
onRowClick?: (name: string) => void;
|
||||
}) {
|
||||
if (!files.length) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>No data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Severity</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((f) => (
|
||||
<tr
|
||||
key={f.name}
|
||||
title={f.name}
|
||||
className={onRowClick ? 'clickable' : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(f.name) : undefined}
|
||||
>
|
||||
<td>{truncPath(f.name, 45)}</td>
|
||||
<td>
|
||||
<SeverityStack high={f.high} medium={f.medium} low={f.low} />
|
||||
</td>
|
||||
<td className="font-mono">{f.score}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function SeverityStack({
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
}: {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}) {
|
||||
const total = high + medium + low;
|
||||
if (total === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="severity-stack"
|
||||
title={`${high} High · ${medium} Medium · ${low} Low`}
|
||||
>
|
||||
{high > 0 && (
|
||||
<div
|
||||
className="sev-segment sev-high"
|
||||
style={{ width: `${(high / total) * 100}%` }}
|
||||
>
|
||||
{high}
|
||||
</div>
|
||||
)}
|
||||
{medium > 0 && (
|
||||
<div
|
||||
className="sev-segment sev-medium"
|
||||
style={{ width: `${(medium / total) * 100}%` }}
|
||||
>
|
||||
{medium}
|
||||
</div>
|
||||
)}
|
||||
{low > 0 && (
|
||||
<div
|
||||
className="sev-segment sev-low"
|
||||
style={{ width: `${(low / total) * 100}%` }}
|
||||
>
|
||||
{low}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── LanguageHealthTable ────────────────────────────────────────────────────
|
||||
|
||||
export function LanguageHealthTable({ rows }: { rows: LanguageHealth[] }) {
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>No data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Language</th>
|
||||
<th>Findings</th>
|
||||
<th>Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.language}>
|
||||
<td>{r.language}</td>
|
||||
<td>{r.findings}</td>
|
||||
<td>
|
||||
<SeverityStack high={r.high} medium={r.medium} low={r.low} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SuppressionHygieneCard ─────────────────────────────────────────────────
|
||||
|
||||
export function SuppressionHygieneCard({
|
||||
hygiene,
|
||||
}: {
|
||||
hygiene: SuppressionHygiene;
|
||||
}) {
|
||||
const total =
|
||||
hygiene.fingerprint_level +
|
||||
hygiene.rule_level +
|
||||
hygiene.file_level +
|
||||
hygiene.rule_in_file_level;
|
||||
const blanket =
|
||||
hygiene.rule_level + hygiene.file_level + hygiene.rule_in_file_level;
|
||||
const blanketDisplay =
|
||||
total > 0 ? `${(hygiene.blanket_rate * 100).toFixed(0)}%` : 'n/a';
|
||||
const blanketDetail =
|
||||
total > 0
|
||||
? `${blanket} of ${total} suppressions are not pinned to a specific finding`
|
||||
: 'No suppressions yet';
|
||||
return (
|
||||
<dl className="kv-list">
|
||||
<div className="kv-row kv-row-emphasis">
|
||||
<dt
|
||||
className="kv-label"
|
||||
title="Share of suppressions that are not pinned to a specific finding fingerprint. Lower is better — it means triage is decisive rather than blanket-silencing whole rules or files."
|
||||
>
|
||||
Blanket rate
|
||||
<span className="kv-hint">Lower is better</span>
|
||||
</dt>
|
||||
<dd className="kv-value">
|
||||
<div className="kv-number">{blanketDisplay}</div>
|
||||
<div className="kv-detail">{blanketDetail}</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<dt
|
||||
className="kv-label"
|
||||
title="Suppressions that target one specific finding by its fingerprint. Most precise."
|
||||
>
|
||||
By fingerprint
|
||||
<span className="kv-hint">Most specific</span>
|
||||
</dt>
|
||||
<dd className="kv-value">
|
||||
<div className="kv-number">{hygiene.fingerprint_level}</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<dt
|
||||
className="kv-label"
|
||||
title="Suppressions that silence a rule only inside a specific file."
|
||||
>
|
||||
By rule in a file
|
||||
</dt>
|
||||
<dd className="kv-value">
|
||||
<div className="kv-number">{hygiene.rule_in_file_level}</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<dt
|
||||
className="kv-label"
|
||||
title="Suppressions that silence an entire rule across the project."
|
||||
>
|
||||
By rule
|
||||
</dt>
|
||||
<dd className="kv-value">
|
||||
<div className="kv-number">{hygiene.rule_level}</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<dt
|
||||
className="kv-label"
|
||||
title="Suppressions that silence everything in a file."
|
||||
>
|
||||
By file
|
||||
<span className="kv-hint">Least specific</span>
|
||||
</dt>
|
||||
<dd className="kv-value">
|
||||
<div className="kv-number">{hygiene.file_level}</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BaselinePinControl ─────────────────────────────────────────────────────
|
||||
|
||||
interface BaselinePinControlProps {
|
||||
baseline?: BaselineInfo;
|
||||
latestScanId?: string;
|
||||
onPin: (scanId: string) => void;
|
||||
onUnpin: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function BaselinePinControl({
|
||||
baseline,
|
||||
latestScanId,
|
||||
onPin,
|
||||
onUnpin,
|
||||
isPending,
|
||||
}: BaselinePinControlProps) {
|
||||
const navigate = useNavigate();
|
||||
if (baseline) {
|
||||
const net = baseline.drift_new - baseline.drift_fixed;
|
||||
const driftClass =
|
||||
net > 0
|
||||
? 'baseline-drift-bad'
|
||||
: net < 0
|
||||
? 'baseline-drift-good'
|
||||
: 'baseline-drift-flat';
|
||||
return (
|
||||
<div className="baseline-strip">
|
||||
<span className="baseline-label">Baseline:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="baseline-link"
|
||||
onClick={() => navigate(`/scans/${baseline.scan_id}`)}
|
||||
>
|
||||
{baseline.started_at
|
||||
? new Date(baseline.started_at).toLocaleDateString()
|
||||
: baseline.scan_id.slice(0, 8)}
|
||||
</button>
|
||||
<span className={driftClass}>
|
||||
drift: +{baseline.drift_new} new / -{baseline.drift_fixed} fixed (
|
||||
{net >= 0 ? '+' : ''}
|
||||
{net})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="baseline-action"
|
||||
onClick={onUnpin}
|
||||
disabled={isPending}
|
||||
>
|
||||
Unpin
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!latestScanId) return null;
|
||||
return (
|
||||
<div className="baseline-strip baseline-strip-empty">
|
||||
<span className="baseline-label">No baseline pinned.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="baseline-action"
|
||||
onClick={() => onPin(latestScanId)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Pin latest scan as baseline
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend/src/components/ui/CommandPalette.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export interface PaletteCommand {
|
||||
id: string;
|
||||
/** Visible label. */
|
||||
label: string;
|
||||
/** Optional secondary line — section, hint, shortcut. */
|
||||
hint?: string;
|
||||
/** Group label for visual separation. */
|
||||
group?: string;
|
||||
/** Search aliases beyond the label. */
|
||||
keywords?: string[];
|
||||
/** Optional leading icon. */
|
||||
icon?: ReactNode;
|
||||
/** Optional trailing keyboard hint. */
|
||||
shortcut?: string;
|
||||
/** Either a route to navigate to, or an action callback. One must be set. */
|
||||
to?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
commands: PaletteCommand[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function rank(query: string, cmd: PaletteCommand): number {
|
||||
if (!query) return 0;
|
||||
const q = query.toLowerCase();
|
||||
const haystacks = [cmd.label, cmd.hint ?? '', ...(cmd.keywords ?? [])].map(
|
||||
(s) => s.toLowerCase(),
|
||||
);
|
||||
let best = -1;
|
||||
for (const h of haystacks) {
|
||||
if (h.startsWith(q)) return 100;
|
||||
const idx = h.indexOf(q);
|
||||
if (idx >= 0 && (best < 0 || idx < best)) best = idx;
|
||||
}
|
||||
if (best < 0) return -1;
|
||||
return 50 - best;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
commands,
|
||||
placeholder = 'Type a command or page...',
|
||||
}: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Reset state on each open so the palette feels fresh and the highlight
|
||||
// doesn't stick to a now-filtered-out item.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setHighlight(0);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return commands;
|
||||
return commands
|
||||
.map((cmd) => [cmd, rank(query, cmd)] as const)
|
||||
.filter(([, r]) => r >= 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([cmd]) => cmd);
|
||||
}, [commands, query]);
|
||||
|
||||
// Keep highlight inside the filtered range.
|
||||
useEffect(() => {
|
||||
if (highlight >= filtered.length) setHighlight(0);
|
||||
}, [filtered.length, highlight]);
|
||||
|
||||
const run = useCallback(
|
||||
(cmd: PaletteCommand) => {
|
||||
onClose();
|
||||
if (cmd.action) cmd.action();
|
||||
else if (cmd.to) navigate(cmd.to);
|
||||
},
|
||||
[navigate, onClose],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setHighlight((h) => Math.min(h + 1, filtered.length - 1));
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setHighlight((h) => Math.max(h - 1, 0));
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const cmd = filtered[highlight];
|
||||
if (cmd) run(cmd);
|
||||
}
|
||||
},
|
||||
[filtered, highlight, onClose, run],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Group while preserving filtered order.
|
||||
const groups = new Map<string, PaletteCommand[]>();
|
||||
for (const cmd of filtered) {
|
||||
const g = cmd.group ?? '';
|
||||
const arr = groups.get(g) ?? [];
|
||||
arr.push(cmd);
|
||||
groups.set(g, arr);
|
||||
}
|
||||
|
||||
let runningIndex = 0;
|
||||
return (
|
||||
<div className="palette-overlay" role="dialog" aria-label="Command palette">
|
||||
<div className="palette-backdrop" onClick={onClose} />
|
||||
<div className="palette" role="combobox" aria-expanded="true">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="palette-input"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label="Command search"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<ul className="palette-list" role="listbox">
|
||||
{filtered.length === 0 && (
|
||||
<li className="palette-empty">No matches</li>
|
||||
)}
|
||||
{Array.from(groups.entries()).map(([group, items]) => (
|
||||
<li key={group || '_'} className="palette-group">
|
||||
{group && <div className="palette-group-label">{group}</div>}
|
||||
<ul>
|
||||
{items.map((cmd) => {
|
||||
const idx = runningIndex++;
|
||||
const active = idx === highlight;
|
||||
return (
|
||||
<li
|
||||
key={cmd.id}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`palette-item${active ? ' active' : ''}`}
|
||||
onMouseEnter={() => setHighlight(idx)}
|
||||
onClick={() => run(cmd)}
|
||||
>
|
||||
{cmd.icon && (
|
||||
<span className="palette-icon">{cmd.icon}</span>
|
||||
)}
|
||||
<span className="palette-label">{cmd.label}</span>
|
||||
{cmd.hint && (
|
||||
<span className="palette-hint">{cmd.hint}</span>
|
||||
)}
|
||||
{cmd.shortcut && (
|
||||
<kbd className="palette-shortcut">{cmd.shortcut}</kbd>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,73 @@
|
|||
import { ApiError } from '../../api/client';
|
||||
import { RefreshIcon } from '../icons/Icons';
|
||||
|
||||
interface ErrorStateProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
/** Either a plain message string or any thrown value (Error, ApiError, unknown). */
|
||||
message?: string;
|
||||
error?: unknown;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
}
|
||||
|
||||
export function ErrorState({ title = 'Error', message }: ErrorStateProps) {
|
||||
interface FriendlyError {
|
||||
title: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Translate a thrown value into a title + message + hint we can render. */
|
||||
function friendly(error: unknown, fallbackTitle: string): FriendlyError {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.isNetwork()) {
|
||||
return {
|
||||
title: 'Network error',
|
||||
message: error.message || 'Could not reach the Nyx server.',
|
||||
};
|
||||
}
|
||||
if (error.status === 404) {
|
||||
return { title: 'Not found', message: error.message };
|
||||
}
|
||||
if (error.status === 403) {
|
||||
return { title: 'Forbidden', message: error.message };
|
||||
}
|
||||
if (error.status === 409) {
|
||||
return { title: 'Conflict', message: error.message };
|
||||
}
|
||||
if (error.status >= 500) {
|
||||
return {
|
||||
title: 'Server error',
|
||||
message: error.message || 'The Nyx server returned an error.',
|
||||
hint: 'Server logs may have more detail.',
|
||||
};
|
||||
}
|
||||
return { title: fallbackTitle, message: error.message };
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return { title: fallbackTitle, message: error.message };
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return { title: fallbackTitle, message: error };
|
||||
}
|
||||
return { title: fallbackTitle, message: 'An unknown error occurred.' };
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
title,
|
||||
message,
|
||||
error,
|
||||
onRetry,
|
||||
retryLabel = 'Try again',
|
||||
}: ErrorStateProps) {
|
||||
const fallbackTitle = title ?? 'Error';
|
||||
const resolved = error
|
||||
? friendly(error, fallbackTitle)
|
||||
: { title: fallbackTitle, message: message ?? 'An error occurred.' };
|
||||
|
||||
return (
|
||||
<div className="error-state">
|
||||
<h3>{title}</h3>
|
||||
<p>{message}</p>
|
||||
<div className="error-state" role="alert">
|
||||
<h3>{resolved.title}</h3>
|
||||
<p>{resolved.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
/**
|
||||
* Suppresses the spinner for the first ~150ms so trivially-fast queries
|
||||
* don't flash a spinner on screen. The text shows instantly so there's
|
||||
* always *something* — but the visible spin only kicks in if work is
|
||||
* actually slow.
|
||||
*/
|
||||
delaySpinnerMs?: number;
|
||||
}
|
||||
|
||||
export function LoadingState({ message = 'Loading...' }: LoadingStateProps) {
|
||||
return <div className="loading">{message}</div>;
|
||||
return (
|
||||
<div className="loading" role="status" aria-live="polite">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<span className="loading-message">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
87
frontend/src/components/ui/ShortcutsHelp.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
interface ShortcutsHelpProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ROWS: { section: string; rows: Row[] }[] = [
|
||||
{
|
||||
section: 'Global',
|
||||
rows: [
|
||||
{ keys: ['⌘', 'K'], description: 'Open command palette' },
|
||||
{ keys: ['/'], description: 'Focus search (on findings page)' },
|
||||
{ keys: ['?'], description: 'Show this help' },
|
||||
{ keys: ['Esc'], description: 'Close modal / palette' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Findings list',
|
||||
rows: [
|
||||
{ keys: ['j'], description: 'Next finding' },
|
||||
{ keys: ['k'], description: 'Previous finding' },
|
||||
{ keys: ['Enter'], description: 'Open highlighted finding' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Navigation',
|
||||
rows: [
|
||||
{ keys: ['g', 'o'], description: 'Go to Overview' },
|
||||
{ keys: ['g', 'f'], description: 'Go to Findings' },
|
||||
{ keys: ['g', 's'], description: 'Go to Scans' },
|
||||
{ keys: ['g', 'r'], description: 'Go to Rules' },
|
||||
{ keys: ['g', 't'], description: 'Go to Triage' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function ShortcutsHelp({ open, onClose }: ShortcutsHelpProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="palette-overlay"
|
||||
role="dialog"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<div className="palette-backdrop" onClick={onClose} />
|
||||
<div className="shortcuts-modal">
|
||||
<div className="shortcuts-header">
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={onClose}
|
||||
aria-label="Close shortcuts help"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="shortcuts-body">
|
||||
{ROWS.map((section) => (
|
||||
<section key={section.section}>
|
||||
<h3>{section.section}</h3>
|
||||
<dl>
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.description} className="shortcut-row">
|
||||
<dt>
|
||||
{row.keys.map((k, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="shortcut-sep">then</span>}
|
||||
<kbd>{k}</kbd>
|
||||
</span>
|
||||
))}
|
||||
</dt>
|
||||
<dd>{row.description}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/ui/Toaster.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { CloseIcon } from '../icons/Icons';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts, dismiss } = useToast();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="toaster"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast toast-${t.tone}`}
|
||||
role={t.tone === 'error' || t.tone === 'warning' ? 'alert' : 'status'}
|
||||
>
|
||||
<div className="toast-body">
|
||||
{t.title && <div className="toast-title">{t.title}</div>}
|
||||
<div className="toast-message">{t.message}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="toast-close"
|
||||
aria-label="Dismiss notification"
|
||||
onClick={() => dismiss(t.id)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/contexts/ThemeContext.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { usePersistedState } from '../hooks/usePersistedState';
|
||||
|
||||
export type ThemePreference =
|
||||
| 'light'
|
||||
| 'dark'
|
||||
| 'system'
|
||||
| 'hc-light'
|
||||
| 'hc-dark';
|
||||
export type ResolvedTheme = 'light' | 'dark' | 'hc-light' | 'hc-dark';
|
||||
|
||||
interface ThemeContextValue {
|
||||
preference: ThemePreference;
|
||||
resolved: ResolvedTheme;
|
||||
setPreference: (next: ThemePreference) => void;
|
||||
/** Cycle light → dark → system → light. Used by the toolbar toggle. */
|
||||
cycle: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
}
|
||||
|
||||
function resolve(pref: ThemePreference): ResolvedTheme {
|
||||
if (pref === 'system') return systemPrefersDark() ? 'dark' : 'light';
|
||||
return pref;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [preference, setPreference] = usePersistedState<ThemePreference>(
|
||||
'theme',
|
||||
'system',
|
||||
);
|
||||
|
||||
const resolved = useMemo(() => resolve(preference), [preference]);
|
||||
|
||||
// Reflect the resolved theme onto <html> so CSS rules under
|
||||
// [data-theme="dark"] take effect.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}, [resolved]);
|
||||
|
||||
// When the user picks "system", react to OS-level changes live.
|
||||
useEffect(() => {
|
||||
if (preference !== 'system') return;
|
||||
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!mq) return;
|
||||
const handler = () => {
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
systemPrefersDark() ? 'dark' : 'light',
|
||||
);
|
||||
};
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [preference]);
|
||||
|
||||
const cycle = useCallback(() => {
|
||||
setPreference((prev) => {
|
||||
if (prev === 'hc-light') return 'hc-dark';
|
||||
if (prev === 'hc-dark') return 'hc-light';
|
||||
if (prev === 'light') return 'dark';
|
||||
if (prev === 'dark') return 'system';
|
||||
return 'light';
|
||||
});
|
||||
}, [setPreference]);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ preference, resolved, setPreference, cycle }),
|
||||
[preference, resolved, setPreference, cycle],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
|
||||
return ctx;
|
||||
}
|
||||
97
frontend/src/contexts/ToastContext.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type ToastTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
tone: ToastTone;
|
||||
title?: string;
|
||||
message: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
push: (
|
||||
t: Omit<Toast, 'id' | 'durationMs'> & { durationMs?: number },
|
||||
) => number;
|
||||
dismiss: (id: number) => void;
|
||||
/** Convenience helpers — call sites read more naturally as toast.error('…'). */
|
||||
info: (message: string, title?: string) => number;
|
||||
success: (message: string, title?: string) => number;
|
||||
warning: (message: string, title?: string) => number;
|
||||
error: (message: string, title?: string) => number;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const DEFAULT_DURATION: Record<ToastTone, number> = {
|
||||
info: 4000,
|
||||
success: 4000,
|
||||
warning: 6000,
|
||||
// Error toasts stick longer — failures usually need a deliberate read.
|
||||
error: 8000,
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const nextId = useRef(1);
|
||||
const timers = useRef(new Map<number, number>());
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
const handle = timers.current.get(id);
|
||||
if (handle !== undefined) {
|
||||
window.clearTimeout(handle);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const push = useCallback<ToastContextValue['push']>(
|
||||
({ tone, title, message, durationMs }) => {
|
||||
const id = nextId.current++;
|
||||
const duration = durationMs ?? DEFAULT_DURATION[tone];
|
||||
setToasts((prev) => [
|
||||
...prev,
|
||||
{ id, tone, title, message, durationMs: duration },
|
||||
]);
|
||||
if (duration > 0) {
|
||||
const handle = window.setTimeout(() => dismiss(id), duration);
|
||||
timers.current.set(id, handle);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const value = useMemo<ToastContextValue>(
|
||||
() => ({
|
||||
toasts,
|
||||
push,
|
||||
dismiss,
|
||||
info: (message, title) => push({ tone: 'info', message, title }),
|
||||
success: (message, title) => push({ tone: 'success', message, title }),
|
||||
warning: (message, title) => push({ tone: 'warning', message, title }),
|
||||
error: (message, title) => push({ tone: 'error', message, title }),
|
||||
}),
|
||||
[toasts, push, dismiss],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>{children}</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
|
||||
return ctx;
|
||||
}
|
||||
62
frontend/src/hooks/useChordNavigation.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const CHORD_TIMEOUT_MS = 800;
|
||||
|
||||
const ROUTES: Record<string, string> = {
|
||||
o: '/',
|
||||
f: '/findings',
|
||||
s: '/scans',
|
||||
r: '/rules',
|
||||
t: '/triage',
|
||||
c: '/config',
|
||||
e: '/explorer',
|
||||
d: '/debug',
|
||||
};
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vim-style "g then X" navigation: press `g`, then within 800ms press a
|
||||
* letter to jump to that section. Cancels if the user types in an input.
|
||||
*/
|
||||
export function useChordNavigation() {
|
||||
const navigate = useNavigate();
|
||||
const armed = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isTypingTarget(event.target)) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
|
||||
if (armed.current !== null) {
|
||||
const route = ROUTES[event.key.toLowerCase()];
|
||||
window.clearTimeout(armed.current);
|
||||
armed.current = null;
|
||||
if (route) {
|
||||
event.preventDefault();
|
||||
navigate(route);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'g') {
|
||||
event.preventDefault();
|
||||
armed.current = window.setTimeout(() => {
|
||||
armed.current = null;
|
||||
}, CHORD_TIMEOUT_MS);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
if (armed.current !== null) window.clearTimeout(armed.current);
|
||||
};
|
||||
}, [navigate]);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { usePersistedState } from './usePersistedState';
|
||||
|
||||
export interface FindingsURLState {
|
||||
page: string;
|
||||
|
|
@ -29,6 +30,21 @@ const FINDINGS_DEFAULTS: FindingsURLState = {
|
|||
search: '',
|
||||
};
|
||||
|
||||
/** Subset of state we remember across sessions. Filters intentionally are
|
||||
* NOT persisted — they're scan-specific and should reset by default, but the
|
||||
* URL still reflects them so a shared link reproduces them exactly. */
|
||||
interface PersistedFindingsPrefs {
|
||||
per_page: string;
|
||||
sort_by: string;
|
||||
sort_dir: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: PersistedFindingsPrefs = {
|
||||
per_page: '50',
|
||||
sort_by: '',
|
||||
sort_dir: 'asc',
|
||||
};
|
||||
|
||||
const FILTER_KEYS: ReadonlySet<string> = new Set([
|
||||
'severity',
|
||||
'category',
|
||||
|
|
@ -49,16 +65,42 @@ const NON_RESET_KEYS: ReadonlySet<string> = new Set([
|
|||
|
||||
export function useFindingsURLState() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [prefs, setPrefs] = usePersistedState<PersistedFindingsPrefs>(
|
||||
'findings:prefs',
|
||||
DEFAULT_PREFS,
|
||||
);
|
||||
|
||||
const state: FindingsURLState = useMemo(() => {
|
||||
const s = {} as FindingsURLState;
|
||||
for (const key of Object.keys(
|
||||
FINDINGS_DEFAULTS,
|
||||
) as (keyof FindingsURLState)[]) {
|
||||
s[key] = searchParams.get(key) || FINDINGS_DEFAULTS[key];
|
||||
// URL wins; fall back to remembered prefs for keys we persist;
|
||||
// last resort is the global default.
|
||||
const fromUrl = searchParams.get(key);
|
||||
if (fromUrl) {
|
||||
s[key] = fromUrl;
|
||||
} else if (
|
||||
key === 'per_page' ||
|
||||
key === 'sort_by' ||
|
||||
key === 'sort_dir'
|
||||
) {
|
||||
s[key] = prefs[key] || FINDINGS_DEFAULTS[key];
|
||||
} else {
|
||||
s[key] = FINDINGS_DEFAULTS[key];
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}, [searchParams]);
|
||||
}, [searchParams, prefs]);
|
||||
|
||||
// Persist user-driven changes to per_page / sort_*.
|
||||
useEffect(() => {
|
||||
setPrefs({
|
||||
per_page: state.per_page,
|
||||
sort_by: state.sort_by,
|
||||
sort_dir: state.sort_dir,
|
||||
});
|
||||
}, [state.per_page, state.sort_by, state.sort_dir, setPrefs]);
|
||||
|
||||
const updateState = useCallback(
|
||||
(updates: Partial<FindingsURLState>) => {
|
||||
|
|
|
|||
63
frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
export interface Shortcut {
|
||||
/** Key string per `KeyboardEvent.key` (e.g. "k", "/", "Escape"). */
|
||||
key: string;
|
||||
/** Require Cmd/Ctrl (matches the same on each OS). */
|
||||
meta?: boolean;
|
||||
/** Require Shift. */
|
||||
shift?: boolean;
|
||||
/** Require Alt. */
|
||||
alt?: boolean;
|
||||
description: string;
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
/**
|
||||
* If true, the shortcut still fires when focus is in an input/textarea/
|
||||
* contenteditable. Default is false — shortcuts shouldn't hijack typing.
|
||||
*/
|
||||
allowInInput?: boolean;
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function matches(event: KeyboardEvent, shortcut: Shortcut): boolean {
|
||||
if (event.key !== shortcut.key) return false;
|
||||
const wantMeta = !!shortcut.meta;
|
||||
const hasMeta = event.metaKey || event.ctrlKey;
|
||||
if (wantMeta !== hasMeta) return false;
|
||||
if (!!shortcut.shift !== event.shiftKey) return false;
|
||||
if (!!shortcut.alt !== event.altKey) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a list of keyboard shortcuts at the document level.
|
||||
*
|
||||
* Pass a stable array (memoize or hoist outside the component) to avoid
|
||||
* unnecessary re-binding. Shortcuts with `meta: true` match either Cmd or
|
||||
* Ctrl so the same binding works on macOS and Linux/Windows.
|
||||
*/
|
||||
export function useKeyboardShortcuts(shortcuts: Shortcut[]) {
|
||||
useEffect(() => {
|
||||
if (shortcuts.length === 0) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const typing = isTypingTarget(event.target);
|
||||
for (const sc of shortcuts) {
|
||||
if (typing && !sc.allowInInput) continue;
|
||||
if (matches(event, sc)) {
|
||||
event.preventDefault();
|
||||
sc.handler(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [shortcuts]);
|
||||
}
|
||||
19
frontend/src/hooks/usePageTitle.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
const APP_NAME = 'Nyx';
|
||||
|
||||
/**
|
||||
* Sets `document.title` to `<page> · Nyx`. Restores the previous title on
|
||||
* unmount so transient pages (e.g. modals that re-render the page) don't
|
||||
* leave the title stuck.
|
||||
*/
|
||||
export function usePageTitle(title: string | null | undefined) {
|
||||
useEffect(() => {
|
||||
if (!title) return;
|
||||
const prev = document.title;
|
||||
document.title = `${title} · ${APP_NAME}`;
|
||||
return () => {
|
||||
document.title = prev;
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
59
frontend/src/hooks/usePersistedState.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const STORAGE_PREFIX = 'nyx:';
|
||||
|
||||
function storageKey(key: string) {
|
||||
return `${STORAGE_PREFIX}${key}`;
|
||||
}
|
||||
|
||||
function read<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(key));
|
||||
if (raw === null) return fallback;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function write<T>(key: string, value: T): void {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(key), JSON.stringify(value));
|
||||
} catch {
|
||||
// Quota exceeded or storage disabled — silently degrade.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `useState` that persists to `localStorage` under `nyx:<key>`.
|
||||
*
|
||||
* Suitable for view preferences (theme, sidebar collapse, default page size).
|
||||
* Not suitable for sensitive data — `localStorage` is not encrypted.
|
||||
*
|
||||
* Cross-tab sync is not implemented; if the user opens two tabs they get
|
||||
* independent state until next load. That's the common-case ergonomic.
|
||||
*/
|
||||
export function usePersistedState<T>(
|
||||
key: string,
|
||||
initial: T,
|
||||
): [T, (next: T | ((prev: T) => T)) => void] {
|
||||
const [state, setState] = useState<T>(() => read(key, initial));
|
||||
|
||||
// Avoid writing back the initial value on first mount when nothing changed.
|
||||
const hydrated = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hydrated.current) {
|
||||
hydrated.current = true;
|
||||
return;
|
||||
}
|
||||
write(key, state);
|
||||
}, [key, state]);
|
||||
|
||||
const set = useCallback((next: T | ((prev: T) => T)) => {
|
||||
setState((prev) =>
|
||||
typeof next === 'function' ? (next as (p: T) => T)(prev) : next,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return [state, set];
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import { useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { useHealth } from '../api/queries/health';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { ApiError } from '../api/client';
|
||||
import {
|
||||
useStartScan,
|
||||
type ScanMode,
|
||||
|
|
@ -31,6 +33,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
|||
const { data: health } = useHealth();
|
||||
const startScan = useStartScan();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const defaultRoot = health?.scan_root || '';
|
||||
const [scanRoot, setScanRoot] = useState('');
|
||||
const [mode, setMode] = useState<ScanMode>('full');
|
||||
|
|
@ -45,10 +48,17 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
|||
const payload = Object.keys(body).length ? body : undefined;
|
||||
try {
|
||||
await startScan.mutateAsync(payload);
|
||||
toast.success('Scan started', 'Started');
|
||||
onClose();
|
||||
navigate('/scans');
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to start scan');
|
||||
const msg =
|
||||
e instanceof ApiError && e.status === 409
|
||||
? 'A scan is already running'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: 'Failed to start scan';
|
||||
toast.error(msg, 'Could not start scan');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ApiError } from '../api/client';
|
|||
import { FileTree } from '../components/data-display/FileTree';
|
||||
import { CodeViewer } from '../components/data-display/CodeViewer';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import { EmptyState } from '../components/ui/EmptyState';
|
||||
import { ExplorerIcon } from '../components/icons/Icons';
|
||||
import { useFileTree } from '../hooks/useFileTree';
|
||||
|
|
@ -20,6 +21,9 @@ import { TaintAnalysisPanel } from './debug/TaintViewerPage';
|
|||
import { SummaryAnalysisPanel } from './debug/SummaryExplorerPage';
|
||||
import { AbstractInterpAnalysisPanel } from './debug/AbstractInterpPage';
|
||||
import { SymexAnalysisPanel } from './debug/SymexPage';
|
||||
import { PointerAnalysisPanel } from './debug/PointerViewerPage';
|
||||
import { TypeFactsAnalysisPanel } from './debug/TypeFactsPage';
|
||||
import { AuthAnalysisPanel } from './debug/AuthAnalysisPage';
|
||||
import type { TreeEntry, FlowStep, FindingView } from '../api/types';
|
||||
|
||||
type ExplorerMode = 'tree' | 'symbols' | 'hotspots';
|
||||
|
|
@ -30,7 +34,10 @@ type ExplorerView =
|
|||
| 'taint'
|
||||
| 'summaries'
|
||||
| 'abstract-interp'
|
||||
| 'symex';
|
||||
| 'symex'
|
||||
| 'pointer'
|
||||
| 'type-facts'
|
||||
| 'auth';
|
||||
|
||||
const FLOW_KIND_COLORS: Record<string, string> = {
|
||||
source: 'var(--success)',
|
||||
|
|
@ -76,13 +83,28 @@ const VIEW_CONFIG: Array<{
|
|||
requiresFunction: true,
|
||||
supportsFunction: true,
|
||||
},
|
||||
{
|
||||
id: 'pointer',
|
||||
label: 'Pointer',
|
||||
requiresFunction: true,
|
||||
supportsFunction: true,
|
||||
},
|
||||
{
|
||||
id: 'type-facts',
|
||||
label: 'Type Facts',
|
||||
requiresFunction: true,
|
||||
supportsFunction: true,
|
||||
},
|
||||
{ id: 'auth', label: 'Auth' },
|
||||
];
|
||||
|
||||
const VIEW_CONFIG_BY_ID = new Map(VIEW_CONFIG.map((view) => [view.id, view]));
|
||||
|
||||
export function ExplorerPage() {
|
||||
usePageTitle('Explorer');
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [explorerMode, setExplorerMode] = useState<ExplorerMode>('tree');
|
||||
const [showClosures, setShowClosures] = useState(false);
|
||||
const [highlightLine, setHighlightLine] = useState<number | undefined>();
|
||||
const [selectedFindingIndex, setSelectedFindingIndex] = useState<
|
||||
number | null
|
||||
|
|
@ -130,6 +152,18 @@ export function ExplorerPage() {
|
|||
|
||||
const { data: symbolEntries, error: symbolsError } =
|
||||
useExplorerSymbols(rawFile);
|
||||
|
||||
const closureSymbolCount = useMemo(
|
||||
() => symbolEntries?.filter((s) => s.func_kind === 'closure').length ?? 0,
|
||||
[symbolEntries],
|
||||
);
|
||||
|
||||
const visibleSymbolEntries = useMemo(() => {
|
||||
if (!symbolEntries) return symbolEntries;
|
||||
return showClosures
|
||||
? symbolEntries
|
||||
: symbolEntries.filter((s) => s.func_kind !== 'closure');
|
||||
}, [symbolEntries, showClosures]);
|
||||
const hasInvalidFile = Boolean(
|
||||
rawFile && isPathResolutionError(symbolsError),
|
||||
);
|
||||
|
|
@ -315,8 +349,21 @@ export function ExplorerPage() {
|
|||
{selectedFile && symbolEntries && symbolEntries.length === 0 && (
|
||||
<div className="explorer-hint">No symbols found</div>
|
||||
)}
|
||||
{selectedFile && closureSymbolCount > 0 && (
|
||||
<label className="explorer-symbol-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showClosures}
|
||||
onChange={(e) => setShowClosures(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Show {closureSymbolCount} anonymous closure
|
||||
{closureSymbolCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{selectedFile &&
|
||||
symbolEntries?.map((sym, index) => (
|
||||
visibleSymbolEntries?.map((sym, index) => (
|
||||
<div
|
||||
key={`${sym.name}-${index}`}
|
||||
className="explorer-symbol-item"
|
||||
|
|
@ -328,6 +375,16 @@ export function ExplorerPage() {
|
|||
{sym.arity !== undefined && sym.arity !== null && (
|
||||
<span className="symbol-arity">({sym.arity})</span>
|
||||
)}
|
||||
{sym.func_kind === 'closure' && (
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ marginLeft: 6, fontSize: '0.85em' }}
|
||||
>
|
||||
{sym.container
|
||||
? `[closure in ${sym.container}]`
|
||||
: '[closure]'}
|
||||
</span>
|
||||
)}
|
||||
{sym.finding_count > 0 && (
|
||||
<span className="tree-node-badge">
|
||||
{sym.finding_count}
|
||||
|
|
@ -378,14 +435,12 @@ export function ExplorerPage() {
|
|||
</span>
|
||||
</div>
|
||||
{selectedFile && currentViewConfig.supportsFunction && (
|
||||
<div className="explorer-function-picker">
|
||||
<FunctionSelector
|
||||
file={selectedFile}
|
||||
selectedFunction={selectedFunction}
|
||||
onFunctionChange={handleFunctionChange}
|
||||
showFilePath={false}
|
||||
/>
|
||||
</div>
|
||||
<FunctionSelector
|
||||
file={selectedFile}
|
||||
selectedFunction={selectedFunction}
|
||||
onFunctionChange={handleFunctionChange}
|
||||
showFilePath={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -500,7 +555,7 @@ export function ExplorerPage() {
|
|||
{symbolEntries && symbolEntries.length === 0 && (
|
||||
<div className="explorer-hint">No symbols found</div>
|
||||
)}
|
||||
{symbolEntries?.map((sym, index) => (
|
||||
{visibleSymbolEntries?.map((sym, index) => (
|
||||
<div
|
||||
key={`${sym.name}-${index}`}
|
||||
className="explorer-symbol-item compact"
|
||||
|
|
@ -509,8 +564,26 @@ export function ExplorerPage() {
|
|||
{sym.kind === 'function' ? 'ƒ' : 'm'}
|
||||
</span>
|
||||
<span className="symbol-name">{sym.name}</span>
|
||||
{sym.func_kind === 'closure' && (
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ marginLeft: 6, fontSize: '0.85em' }}
|
||||
>
|
||||
[closure]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!showClosures && closureSymbolCount > 0 && (
|
||||
<button
|
||||
className="explorer-symbol-toggle-link"
|
||||
type="button"
|
||||
onClick={() => setShowClosures(true)}
|
||||
>
|
||||
Show {closureSymbolCount} closure
|
||||
{closureSymbolCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="explorer-right-section">
|
||||
|
|
@ -601,6 +674,14 @@ function renderAnalysisContent({
|
|||
);
|
||||
}
|
||||
|
||||
if (currentView === 'auth') {
|
||||
return (
|
||||
<div className="explorer-analysis-content">
|
||||
<AuthAnalysisPanel file={selectedFile} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (functionsLoading) {
|
||||
return <LoadingState message="Loading functions..." />;
|
||||
}
|
||||
|
|
@ -664,6 +745,24 @@ function renderAnalysisContent({
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
case 'pointer':
|
||||
return (
|
||||
<div className="explorer-analysis-content">
|
||||
<PointerAnalysisPanel
|
||||
file={selectedFile}
|
||||
functionName={selectedFunction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'type-facts':
|
||||
return (
|
||||
<div className="explorer-analysis-content">
|
||||
<TypeFactsAnalysisPanel
|
||||
file={selectedFile}
|
||||
functionName={selectedFunction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'code':
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useFinding } from '../api/queries/findings';
|
||||
import { useBulkTriage } from '../api/mutations/triage';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import { truncPath } from '../utils/truncPath';
|
||||
import { escapeHtml, highlightSyntax } from '../utils/syntaxHighlight';
|
||||
import { parseNoteText } from '../utils/parseNote';
|
||||
|
|
@ -816,6 +817,11 @@ export function FindingDetailPage() {
|
|||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: finding, isLoading, isError, error } = useFinding(id ?? '');
|
||||
usePageTitle(
|
||||
finding
|
||||
? `${finding.rule_id} · ${finding.path}:${finding.line}`
|
||||
: 'Finding',
|
||||
);
|
||||
|
||||
const bulkTriage = useBulkTriage();
|
||||
const [codeModalOpen, setCodeModalOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useFindingsURLState } from '../hooks/useFindingsURLState';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import {
|
||||
useFindings,
|
||||
useFindingFilters,
|
||||
|
|
@ -11,9 +14,12 @@ import {
|
|||
import { useBulkTriage, useAddSuppression } from '../api/mutations/triage';
|
||||
import { Pagination } from '../components/ui/Pagination';
|
||||
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 { truncPath } from '../utils/truncPath';
|
||||
import { findingsToMarkdown } from '../utils/findingMarkdown';
|
||||
import { ApiError } from '../api/client';
|
||||
import type { FindingView, FilterValues } from '../api/types';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -279,8 +285,10 @@ function SortableTh({
|
|||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function FindingsPage() {
|
||||
usePageTitle('Findings');
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const { state, updateState, resetFilters, hasActiveFilters } =
|
||||
useFindingsURLState();
|
||||
|
||||
|
|
@ -388,10 +396,22 @@ export function FindingsPage() {
|
|||
if (fingerprints.length === 0) return;
|
||||
bulkTriage.mutate(
|
||||
{ fingerprints, state: triageState, note: '' },
|
||||
{ onSuccess: () => setSelected(new Set()) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
toast.success(
|
||||
`Marked ${fingerprints.length} finding${fingerprints.length === 1 ? '' : 's'} as ${triageState.replace('_', ' ')}`,
|
||||
);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Bulk triage failed',
|
||||
'Could not update findings',
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
[getSelectedFingerprints, bulkTriage],
|
||||
[getSelectedFingerprints, bulkTriage, toast],
|
||||
);
|
||||
|
||||
const handleSuppressByPattern = useCallback(() => {
|
||||
|
|
@ -435,7 +455,13 @@ export function FindingsPage() {
|
|||
onSuccess: () => {
|
||||
setSuppressModalOpen(false);
|
||||
setSelected(new Set());
|
||||
toast.success(`Added suppression by ${by}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Suppression failed',
|
||||
'Could not add suppression',
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
@ -470,15 +496,61 @@ export function FindingsPage() {
|
|||
[navigate],
|
||||
);
|
||||
|
||||
// ── Keyboard navigation: j/k row cursor + / search + Enter to open ──
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [cursor, setCursor] = useState(-1);
|
||||
|
||||
// Reset cursor whenever the visible page changes.
|
||||
useEffect(() => {
|
||||
setCursor(-1);
|
||||
}, [data]);
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: '/',
|
||||
description: 'Focus search',
|
||||
handler: () => searchInputRef.current?.focus(),
|
||||
},
|
||||
{
|
||||
key: 'j',
|
||||
description: 'Next finding',
|
||||
handler: () => {
|
||||
if (!data || data.findings.length === 0) return;
|
||||
setCursor((c) => Math.min(c + 1, data.findings.length - 1));
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'k',
|
||||
description: 'Previous finding',
|
||||
handler: () => {
|
||||
if (!data || data.findings.length === 0) return;
|
||||
setCursor((c) => Math.max(c - 1, 0));
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Enter',
|
||||
description: 'Open highlighted finding',
|
||||
handler: () => {
|
||||
const f = data?.findings[cursor];
|
||||
if (f) navigate(`/findings/${f.index}`);
|
||||
},
|
||||
},
|
||||
],
|
||||
[data, cursor, navigate],
|
||||
);
|
||||
|
||||
useKeyboardShortcuts(shortcuts);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="loading">Loading findings...</div>;
|
||||
return <LoadingState message="Loading findings..." />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (msg.includes('404')) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h3>No scan results yet</h3>
|
||||
|
|
@ -486,12 +558,7 @@ export function FindingsPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="error-state">
|
||||
<h3>Error</h3>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
);
|
||||
return <ErrorState title="Error" error={error} />;
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
|
@ -513,6 +580,7 @@ export function FindingsPage() {
|
|||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
ref={searchInputRef}
|
||||
placeholder="Search findings... (/)"
|
||||
className="search-input"
|
||||
value={searchInput}
|
||||
|
|
@ -654,10 +722,11 @@ export function FindingsPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.findings.map((f) => (
|
||||
{data.findings.map((f, i) => (
|
||||
<tr
|
||||
key={f.index}
|
||||
className={`clickable${selected.has(f.index) ? ' selected' : ''}`}
|
||||
className={`clickable${selected.has(f.index) ? ' selected' : ''}${i === cursor ? ' cursor' : ''}`}
|
||||
aria-current={i === cursor ? 'true' : undefined}
|
||||
onClick={(e) => handleRowClick(e, f)}
|
||||
>
|
||||
<td className="col-checkbox">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOverview, useOverviewTrends } from '../api/queries/overview';
|
||||
import { usePinBaseline, useUnpinBaseline } from '../api/mutations/baseline';
|
||||
import { StatCard } from '../components/ui/StatCard';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
|
|
@ -7,12 +8,28 @@ import { HorizontalBarChart } from '../components/charts/HorizontalBarChart';
|
|||
import { LineChart } from '../components/charts/LineChart';
|
||||
import { OverviewIcon } from '../components/icons/Icons';
|
||||
import { truncPath } from '../utils/truncPath';
|
||||
import {
|
||||
HealthScoreCard,
|
||||
BacklogCard,
|
||||
ConfidenceDistributionChart,
|
||||
ScannerQualityPanel,
|
||||
HotSinksList,
|
||||
OwaspChart,
|
||||
WeightedTopFiles,
|
||||
LanguageHealthTable,
|
||||
SuppressionHygieneCard,
|
||||
BaselinePinControl,
|
||||
} from '../components/overview/OverviewWidgets';
|
||||
import type { OverviewCount, ScanSummary, Insight } from '../api/types';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
|
||||
export function OverviewPage() {
|
||||
usePageTitle('Overview');
|
||||
const navigate = useNavigate();
|
||||
const { data: overview, isLoading, error } = useOverview();
|
||||
const { data: overview, isLoading, error, refetch } = useOverview();
|
||||
const { data: trends } = useOverviewTrends();
|
||||
const pinBaseline = usePinBaseline();
|
||||
const unpinBaseline = useUnpinBaseline();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading overview..." />;
|
||||
|
|
@ -22,7 +39,8 @@ export function OverviewPage() {
|
|||
return (
|
||||
<ErrorState
|
||||
title="Error loading overview"
|
||||
message={(error as Error).message}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,41 +55,43 @@ export function OverviewPage() {
|
|||
<div className="overview-empty">
|
||||
<OverviewIcon size={48} />
|
||||
<h2>Welcome to Nyx</h2>
|
||||
<p>Start your first scan to see security findings and analytics.</p>
|
||||
<p>Run your first scan to see security findings and analytics.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data preparation
|
||||
const netDelta = overview.new_since_last - overview.fixed_since_last;
|
||||
|
||||
const sevItems = (['HIGH', 'MEDIUM', 'LOW'] as const).map((s) => ({
|
||||
label: s.charAt(0) + s.slice(1).toLowerCase(),
|
||||
value: overview.by_severity[s] || 0,
|
||||
color: s === 'HIGH' ? '#e74c3c' : s === 'MEDIUM' ? '#e67e22' : '#3498db',
|
||||
}));
|
||||
|
||||
const catItems = Object.entries(overview.by_category || {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
const categoryItems = (overview.issue_categories || [])
|
||||
.slice(0, 8)
|
||||
.map(([k, v]) => ({ label: k, value: v, color: '#5856d6' }));
|
||||
|
||||
const langItems = Object.entries(overview.by_language || {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([k, v]) => ({ label: k, value: v, color: '#5856d6' }));
|
||||
.map((b) => ({ label: b.label, value: b.count, color: '#5856d6' }));
|
||||
|
||||
const trendData = (trends || []).map((t) => ({
|
||||
label: t.timestamp,
|
||||
value: t.total,
|
||||
}));
|
||||
|
||||
const hotSinks = overview.hot_sinks || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>Overview</h2>
|
||||
</div>
|
||||
|
||||
{/* Baseline strip */}
|
||||
<BaselinePinControl
|
||||
baseline={overview.baseline}
|
||||
latestScanId={overview.latest_scan_id}
|
||||
onPin={(id) => pinBaseline.mutate(id)}
|
||||
onUnpin={() => unpinBaseline.mutate()}
|
||||
isPending={pinBaseline.isPending || unpinBaseline.isPending}
|
||||
/>
|
||||
|
||||
{overview.health && (
|
||||
<HealthScoreCard health={overview.health} posture={overview.posture} />
|
||||
)}
|
||||
|
||||
{/* Fresh banner */}
|
||||
{overview.state === 'fresh' && (
|
||||
<div className="overview-fresh-banner">
|
||||
|
|
@ -97,8 +117,9 @@ export function OverviewPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="overview-stat-grid">
|
||||
{/* Stat cards — kept lean: 5 cards, severity stacks live in Top Files
|
||||
and Per-Language. Cross-file / Symex moved into Scanner Quality. */}
|
||||
<div className="overview-stat-grid overview-stat-grid-5">
|
||||
<StatCard
|
||||
label="Total Findings"
|
||||
value={overview.total_findings}
|
||||
|
|
@ -122,50 +143,77 @@ export function OverviewPage() {
|
|||
label="Triage Coverage"
|
||||
value={`${(overview.triage_coverage * 100).toFixed(0)}%`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Scan Duration"
|
||||
value={
|
||||
overview.latest_scan_duration_secs != null
|
||||
? `${overview.latest_scan_duration_secs.toFixed(1)}s`
|
||||
: '-'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="overview-chart-grid">
|
||||
<div className="card">
|
||||
<div className="card-header">Findings Over Time</div>
|
||||
<LineChart points={trendData} />
|
||||
{trendData.length >= 2 ? (
|
||||
<LineChart points={trendData} />
|
||||
) : (
|
||||
<div className="empty-state" style={{ padding: 16 }}>
|
||||
<p>Run a second scan to see trends.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">By Severity</div>
|
||||
<HorizontalBarChart items={sevItems} />
|
||||
<div className="card-header">OWASP Top 10 (2021)</div>
|
||||
{overview.owasp_buckets && overview.owasp_buckets.length > 0 ? (
|
||||
<OwaspChart buckets={overview.owasp_buckets} />
|
||||
) : (
|
||||
<div className="empty-state" style={{ padding: 16 }}>
|
||||
<p>No OWASP-mapped findings.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">By Category</div>
|
||||
<HorizontalBarChart items={catItems} />
|
||||
<div className="card-header">Confidence Distribution</div>
|
||||
{overview.confidence_distribution ? (
|
||||
<ConfidenceDistributionChart
|
||||
dist={overview.confidence_distribution}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state" style={{ padding: 16 }}>
|
||||
<p>No data</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">By Language</div>
|
||||
<HorizontalBarChart items={langItems} />
|
||||
<div className="card-header">Issue Categories</div>
|
||||
<HorizontalBarChart items={categoryItems} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
{/* Per-language + Top Files */}
|
||||
<div className="overview-table-grid">
|
||||
<div className="card">
|
||||
<div className="card-header">Top Affected Files</div>
|
||||
<CompactTable
|
||||
items={overview.top_files}
|
||||
nameLabel="File"
|
||||
countLabel="Findings"
|
||||
truncate
|
||||
onRowClick={(item) =>
|
||||
navigate(`/findings?search=${encodeURIComponent(item.name)}`)
|
||||
<div className="card-header">Per-Language Posture</div>
|
||||
<LanguageHealthTable rows={overview.language_health || []} />
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
Top Affected Files (severity-weighted)
|
||||
</div>
|
||||
<WeightedTopFiles
|
||||
files={overview.weighted_top_files || []}
|
||||
onRowClick={(name) =>
|
||||
navigate(`/findings?search=${encodeURIComponent(name)}`)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Rules + Top Directories (or Hot Sinks when taint findings exist) */}
|
||||
<div className="overview-table-grid">
|
||||
<div className="card">
|
||||
<div className="card-header">Top Rules Triggered</div>
|
||||
<CompactTable
|
||||
items={overview.top_rules}
|
||||
nameLabel="Rule"
|
||||
countLabel="Findings"
|
||||
/>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">Top Directories</div>
|
||||
<CompactTable
|
||||
|
|
@ -175,36 +223,72 @@ export function OverviewPage() {
|
|||
truncate
|
||||
/>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">Top Rules Triggered</div>
|
||||
<CompactTable
|
||||
items={overview.top_rules}
|
||||
nameLabel="Rule"
|
||||
countLabel="Findings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hotSinks.length > 0 && (
|
||||
<div className="overview-table-grid">
|
||||
<div className="card card-full">
|
||||
<div className="card-header">Hot Sinks (taint flow)</div>
|
||||
<HotSinksList sinks={hotSinks} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overview.backlog && <BacklogCard backlog={overview.backlog} />}
|
||||
|
||||
{/* Scanner Quality + Hygiene */}
|
||||
<div className="overview-table-grid">
|
||||
<div className="card">
|
||||
<div className="card-header">Scanner Quality</div>
|
||||
{overview.scanner_quality ? (
|
||||
<ScannerQualityPanel
|
||||
quality={overview.scanner_quality}
|
||||
crossFileRatio={overview.cross_file_ratio}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state" style={{ padding: 16 }}>
|
||||
<p>No engine metrics available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">Suppression Hygiene</div>
|
||||
{overview.suppression_hygiene ? (
|
||||
<SuppressionHygieneCard hygiene={overview.suppression_hygiene} />
|
||||
) : (
|
||||
<div className="empty-state" style={{ padding: 16 }}>
|
||||
<p>No suppressions</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent scans */}
|
||||
<div className="overview-table-grid">
|
||||
<div className="card">
|
||||
<div className="card-header">Recent Scans</div>
|
||||
<RecentScansTable
|
||||
scans={overview.recent_scans}
|
||||
currentBaselineId={overview.baseline?.scan_id}
|
||||
onRowClick={(scan) => navigate(`/scans/${scan.id}`)}
|
||||
onPinBaseline={(scanId) => pinBaseline.mutate(scanId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
{overview.insights.length > 0 && (
|
||||
<div className="overview-insights">
|
||||
<div className="card">
|
||||
<div className="card-header">Insights</div>
|
||||
<div className="card">
|
||||
<div className="card-header">Insights</div>
|
||||
{overview.insights.length > 0 ? (
|
||||
<div className="insight-list">
|
||||
{overview.insights.map((insight, i) => (
|
||||
<InsightCard key={i} insight={insight} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state" style={{ padding: 16 }}>
|
||||
<p>Nothing to flag.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -264,10 +348,17 @@ function CompactTable({
|
|||
|
||||
interface RecentScansTableProps {
|
||||
scans: ScanSummary[];
|
||||
currentBaselineId?: string;
|
||||
onRowClick: (scan: ScanSummary) => void;
|
||||
onPinBaseline?: (scanId: string) => void;
|
||||
}
|
||||
|
||||
function RecentScansTable({ scans, onRowClick }: RecentScansTableProps) {
|
||||
function RecentScansTable({
|
||||
scans,
|
||||
currentBaselineId,
|
||||
onRowClick,
|
||||
onPinBaseline,
|
||||
}: RecentScansTableProps) {
|
||||
if (!scans || scans.length === 0) {
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: 16 }}>
|
||||
|
|
@ -284,31 +375,50 @@ function RecentScansTable({ scans, onRowClick }: RecentScansTableProps) {
|
|||
<th>Duration</th>
|
||||
<th>Findings</th>
|
||||
<th>Time</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scans.slice(0, 5).map((scan) => (
|
||||
<tr
|
||||
key={scan.id}
|
||||
className="clickable"
|
||||
onClick={() => onRowClick(scan)}
|
||||
>
|
||||
<td>
|
||||
<span className={`status-dot ${scan.status}`} /> {scan.status}
|
||||
</td>
|
||||
<td>
|
||||
{scan.duration_secs != null
|
||||
? `${scan.duration_secs.toFixed(1)}s`
|
||||
: '-'}
|
||||
</td>
|
||||
<td>{scan.finding_count ?? '-'}</td>
|
||||
<td>
|
||||
{scan.started_at
|
||||
? new Date(scan.started_at).toLocaleString()
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{scans.slice(0, 5).map((scan) => {
|
||||
const isBaseline = scan.id === currentBaselineId;
|
||||
const canPin =
|
||||
!isBaseline && onPinBaseline && scan.status === 'completed';
|
||||
return (
|
||||
<tr
|
||||
key={scan.id}
|
||||
className="clickable"
|
||||
onClick={() => onRowClick(scan)}
|
||||
>
|
||||
<td>
|
||||
<span className={`status-dot ${scan.status}`} /> {scan.status}
|
||||
</td>
|
||||
<td>
|
||||
{scan.duration_secs != null
|
||||
? `${scan.duration_secs.toFixed(1)}s`
|
||||
: '-'}
|
||||
</td>
|
||||
<td>{scan.finding_count ?? '-'}</td>
|
||||
<td>
|
||||
{scan.started_at
|
||||
? new Date(scan.started_at).toLocaleString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
{isBaseline ? (
|
||||
<span className="baseline-label">baseline</span>
|
||||
) : canPin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="baseline-action"
|
||||
onClick={() => onPinBaseline!(scan.id)}
|
||||
>
|
||||
Pin
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useRules } from '../api/queries/rules';
|
|||
import { useToggleRule, useCloneRule } from '../api/mutations/rules';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import type { RuleListItem } from '../api/types';
|
||||
|
||||
function useDebounce(value: string, delay: number): string {
|
||||
|
|
@ -200,6 +201,7 @@ function RulesTable({
|
|||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RulesPage() {
|
||||
usePageTitle('Rules');
|
||||
const params = useParams<{ id?: string }>();
|
||||
const { data: rules, isLoading, error } = useRules();
|
||||
const toggleRule = useToggleRule();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||
import { useScanCompare } from '../api/queries/scans';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import type {
|
||||
CompareResponse,
|
||||
ComparedFinding,
|
||||
|
|
@ -275,14 +276,24 @@ function CompareByGroup({
|
|||
type CompareTab = 'status' | 'rule' | 'file';
|
||||
|
||||
export function ScanComparePage() {
|
||||
usePageTitle('Compare scans');
|
||||
const { left, right } = useParams<{ left: string; right: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useScanCompare(left || '', right || '');
|
||||
const { data, isLoading, error, refetch } = useScanCompare(
|
||||
left || '',
|
||||
right || '',
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<CompareTab>('status');
|
||||
|
||||
if (isLoading) return <LoadingState message="Loading comparison..." />;
|
||||
if (error)
|
||||
return <ErrorState title="Comparison failed" message={error.message} />;
|
||||
return (
|
||||
<ErrorState
|
||||
title="Comparison failed"
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
if (!data) return <ErrorState message="No comparison data" />;
|
||||
|
||||
const severities = ['HIGH', 'MEDIUM', 'LOW'];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from '../api/queries/scans';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import type { ScanView, ScanLogEntry, ScanMetricsSnapshot } from '../api/types';
|
||||
|
||||
function truncPath(p?: string, max = 50): string {
|
||||
|
|
@ -384,6 +385,7 @@ export function ScanDetailPage() {
|
|||
const { data: scan, isLoading, error } = useScan(id || '');
|
||||
const { data: allScans } = useScans();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('summary');
|
||||
usePageTitle(scan ? `Scan ${scan.id.slice(0, 8)}` : 'Scan');
|
||||
|
||||
const prevScanId = useMemo(() => {
|
||||
if (!scan || scan.status !== 'completed' || !allScans) return null;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useDeleteScan } from '../api/mutations/scans';
|
|||
import { useSSE } from '../contexts/SSEContext';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import type { ScanView } from '../api/types';
|
||||
|
||||
function relTime(iso?: string): string {
|
||||
|
|
@ -123,6 +124,7 @@ function ScanProgress({
|
|||
}
|
||||
|
||||
export function ScansPage() {
|
||||
usePageTitle('Scans');
|
||||
const navigate = useNavigate();
|
||||
const { data: scans, isLoading, error } = useScans();
|
||||
const deleteScan = useDeleteScan();
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { ICONS } from '../components/icons/Icons';
|
||||
|
||||
const STUB_DESCRIPTIONS: Record<string, string> = {
|
||||
'/explorer':
|
||||
'Browse the scanned codebase, view file trees, and inspect individual files with inline annotations.',
|
||||
'/debug':
|
||||
'Inspect internal analysis state — control flow graphs, SSA IR, call graphs, and taint propagation.',
|
||||
'/debug/cfg':
|
||||
'Visualize control flow graphs for individual functions with block-level detail.',
|
||||
'/debug/ssa':
|
||||
'Inspect SSA intermediate representation including phi nodes, value numbering, and taint state.',
|
||||
'/debug/call-graph':
|
||||
'Explore the inter-procedural call graph with SCC highlighting and topo-order visualization.',
|
||||
'/debug/taint':
|
||||
'Step through taint propagation with per-instruction state snapshots and path tracking.',
|
||||
'/settings': 'Application settings and preferences.',
|
||||
};
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = {
|
||||
'/explorer': 'Explorer',
|
||||
'/debug': 'Debug',
|
||||
'/debug/cfg': 'CFG Viewer',
|
||||
'/debug/ssa': 'SSA Viewer',
|
||||
'/debug/call-graph': 'Call Graph',
|
||||
'/debug/taint': 'Taint Debugger',
|
||||
'/settings': 'Settings',
|
||||
};
|
||||
|
||||
function sectionFromPath(pathname: string): string {
|
||||
if (pathname === '/') return 'overview';
|
||||
const first = pathname.split('/')[1];
|
||||
return first || 'overview';
|
||||
}
|
||||
|
||||
export function StubPage() {
|
||||
const { pathname } = useLocation();
|
||||
const label = ROUTE_LABELS[pathname] ?? sectionFromPath(pathname);
|
||||
const description =
|
||||
STUB_DESCRIPTIONS[pathname] ?? 'This page is under construction.';
|
||||
const section = sectionFromPath(pathname);
|
||||
const IconComponent = ICONS[section];
|
||||
|
||||
return (
|
||||
<div className="stub-page">
|
||||
{IconComponent && (
|
||||
<div className="stub-icon">
|
||||
<IconComponent size={48} />
|
||||
</div>
|
||||
)}
|
||||
<h2 className="stub-title">{label}</h2>
|
||||
<p className="stub-description">{description}</p>
|
||||
<span className="stub-badge">Coming Soon</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { Dropdown, DropdownItem } from '../components/ui/Dropdown';
|
||||
import { usePageTitle } from '../hooks/usePageTitle';
|
||||
import type { FindingView, AuditEntry, SuppressionRule } from '../api/types';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -917,6 +918,7 @@ function AuditLogTab({ entries }: { entries: AuditEntry[] }) {
|
|||
type TriageTab = 'findings' | 'rules' | 'audit';
|
||||
|
||||
export function TriagePage() {
|
||||
usePageTitle('Triage');
|
||||
const [triageFilter, setTriageFilter] = useState('needs_attention');
|
||||
const [activeTab, setActiveTab] = useState<TriageTab>('findings');
|
||||
const [selectedRules, setSelectedRules] = useState<Set<string>>(new Set());
|
||||
|
|
|
|||
309
frontend/src/pages/debug/AuthAnalysisPage.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { useDebugAuth } from '../../api/queries/debug';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { ErrorState } from '../../components/ui/ErrorState';
|
||||
import { LoadingState } from '../../components/ui/LoadingState';
|
||||
import type {
|
||||
AuthAnalysisView,
|
||||
AuthCheckView,
|
||||
AuthOperationView,
|
||||
AuthRouteView,
|
||||
AuthUnitView,
|
||||
AuthValueRefView,
|
||||
} from '../../api/types';
|
||||
|
||||
interface AuthAnalysisPanelProps {
|
||||
file: string;
|
||||
}
|
||||
|
||||
export function AuthAnalysisPanel({ file }: AuthAnalysisPanelProps) {
|
||||
const { data, isLoading, error } = useDebugAuth(file);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Running authorization extraction..." />;
|
||||
}
|
||||
if (error) {
|
||||
if (error instanceof ApiError && error.status === 400) {
|
||||
return (
|
||||
<EmptyState message="Auth analysis only runs on supported source files. Try a .ts / .py / .rb / .rs / .go / .java / .php file." />
|
||||
);
|
||||
}
|
||||
return <ErrorState message="Failed to run authorization extraction." />;
|
||||
}
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data.enabled) {
|
||||
return (
|
||||
<EmptyState message="Authorization analysis is disabled for this file's language. Toggle scanner.auth_analysis.enable in your nyx.toml to opt in." />
|
||||
);
|
||||
}
|
||||
|
||||
if (data.routes.length === 0 && data.units.length === 0) {
|
||||
return (
|
||||
<EmptyState message="No routes or analysis units were extracted from this file. Auth analysis fires on framework route handlers and helper functions whose body matches an authorization-check pattern." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="abstract-interp-viewer">
|
||||
<AuthSummaryHeader data={data} />
|
||||
{data.routes.length > 0 && <AuthRoutesBlock routes={data.routes} />}
|
||||
{data.units.length > 0 && <AuthUnitsBlock units={data.units} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthSummaryHeader({ data }: { data: AuthAnalysisView }) {
|
||||
const totalChecks = data.units.reduce(
|
||||
(acc, u) => acc + u.auth_checks.length,
|
||||
0,
|
||||
);
|
||||
const totalOps = data.units.reduce((acc, u) => acc + u.operations.length, 0);
|
||||
return (
|
||||
<div className="abstract-block">
|
||||
<div className="abstract-block-header">
|
||||
<h3 style={{ margin: 0 }}>Authorization Model</h3>
|
||||
<span className="text-secondary">
|
||||
{data.routes.length} route{data.routes.length === 1 ? '' : 's'} ·{' '}
|
||||
{data.units.length} unit{data.units.length === 1 ? '' : 's'} ·{' '}
|
||||
{totalChecks} auth check{totalChecks === 1 ? '' : 's'} · {totalOps}{' '}
|
||||
sensitive op{totalOps === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthRoutesBlock({ routes }: { routes: AuthRouteView[] }) {
|
||||
return (
|
||||
<div className="abstract-block">
|
||||
<div className="abstract-block-header">
|
||||
<h3 style={{ margin: 0 }}>Routes</h3>
|
||||
<span className="text-secondary">
|
||||
{routes.length} registration{routes.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<table className="abstract-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Framework</th>
|
||||
<th>Middleware</th>
|
||||
<th>Handler Params</th>
|
||||
<th>Line</th>
|
||||
<th>Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routes.map((r, i) => (
|
||||
<tr key={`${r.method}-${r.path}-${i}`}>
|
||||
<td>
|
||||
<span className="cap-badge cap-badge-source">{r.method}</span>
|
||||
</td>
|
||||
<td className="mono">{r.path}</td>
|
||||
<td>{r.framework}</td>
|
||||
<td className="mono">
|
||||
{r.middleware.length > 0 ? r.middleware.join(', ') : '-'}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{r.handler_params.length > 0
|
||||
? r.handler_params.join(', ')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="mono">L{r.line}</td>
|
||||
<td className="mono">#{r.unit_idx}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthUnitsBlock({ units }: { units: AuthUnitView[] }) {
|
||||
return (
|
||||
<>
|
||||
{units.map((u, i) => (
|
||||
<AuthUnitCard key={`${u.name ?? '<anon>'}-${i}`} unit={u} index={i} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthUnitCard({ unit, index }: { unit: AuthUnitView; index: number }) {
|
||||
const hasDetails =
|
||||
unit.params.length > 0 ||
|
||||
unit.self_actor_vars.length > 0 ||
|
||||
unit.typed_bounded_vars.length > 0 ||
|
||||
unit.authorized_sql_vars.length > 0 ||
|
||||
unit.const_bound_vars.length > 0;
|
||||
|
||||
return (
|
||||
<div className="abstract-block">
|
||||
<div className="abstract-block-header">
|
||||
<h3 style={{ margin: 0 }}>
|
||||
#{index} {unit.name ?? '<anonymous>'}
|
||||
<span className="text-secondary" style={{ marginLeft: 8 }}>
|
||||
{unit.kind} · L{unit.line}
|
||||
</span>
|
||||
</h3>
|
||||
<span className="text-secondary">
|
||||
{unit.auth_checks.length} check
|
||||
{unit.auth_checks.length === 1 ? '' : 's'} · {unit.operations.length}{' '}
|
||||
op
|
||||
{unit.operations.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{hasDetails && (
|
||||
<div className="auth-detail-list">
|
||||
{unit.params.length > 0 && (
|
||||
<DetailRow label="Params" value={unit.params.join(', ')} />
|
||||
)}
|
||||
{unit.self_actor_vars.length > 0 && (
|
||||
<DetailRow
|
||||
label="Self-actor vars"
|
||||
value={unit.self_actor_vars.join(', ')}
|
||||
/>
|
||||
)}
|
||||
{unit.typed_bounded_vars.length > 0 && (
|
||||
<DetailRow
|
||||
label="Typed-bounded params"
|
||||
value={unit.typed_bounded_vars.join(', ')}
|
||||
/>
|
||||
)}
|
||||
{unit.authorized_sql_vars.length > 0 && (
|
||||
<DetailRow
|
||||
label="Authorized SQL vars"
|
||||
value={unit.authorized_sql_vars.join(', ')}
|
||||
/>
|
||||
)}
|
||||
{unit.const_bound_vars.length > 0 && (
|
||||
<DetailRow
|
||||
label="Const-bound vars"
|
||||
value={unit.const_bound_vars.join(', ')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unit.auth_checks.length > 0 && (
|
||||
<AuthCheckTable checks={unit.auth_checks} />
|
||||
)}
|
||||
{unit.operations.length > 0 && (
|
||||
<OperationTable operations={unit.operations} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="auth-detail-row">
|
||||
<span className="auth-detail-label">{label}</span>
|
||||
<span className="auth-detail-value mono">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthCheckTable({ checks }: { checks: AuthCheckView[] }) {
|
||||
return (
|
||||
<div className="auth-subsection">
|
||||
<div className="auth-subsection-title">Auth Checks</div>
|
||||
<table className="abstract-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Callee</th>
|
||||
<th>Subjects</th>
|
||||
<th>Line</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c, i) => (
|
||||
<tr key={`${c.callee}-${c.line}-${i}`}>
|
||||
<td>
|
||||
<span className="cap-badge cap-badge-source">{c.kind}</span>
|
||||
</td>
|
||||
<td className="mono">{c.callee}</td>
|
||||
<td>
|
||||
{c.subjects.length === 0 ? (
|
||||
'-'
|
||||
) : (
|
||||
<SubjectChips subjects={c.subjects} />
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">L{c.line}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationTable({ operations }: { operations: AuthOperationView[] }) {
|
||||
return (
|
||||
<div className="auth-subsection">
|
||||
<div className="auth-subsection-title">Sensitive Operations</div>
|
||||
<table className="abstract-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Sink Class</th>
|
||||
<th>Callee</th>
|
||||
<th>Subjects</th>
|
||||
<th>Line</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{operations.map((op, i) => (
|
||||
<tr key={`${op.callee}-${op.line}-${i}`}>
|
||||
<td>
|
||||
<span className="cap-badge cap-badge-sanitizer">{op.kind}</span>
|
||||
</td>
|
||||
<td>
|
||||
{op.sink_class ? (
|
||||
<span className="cap-badge cap-badge-sink">
|
||||
{op.sink_class}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="mono" title={op.text}>
|
||||
{op.callee}
|
||||
</td>
|
||||
<td>
|
||||
{op.subjects.length === 0 ? (
|
||||
'-'
|
||||
) : (
|
||||
<SubjectChips subjects={op.subjects} />
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">L{op.line}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubjectChips({ subjects }: { subjects: AuthValueRefView[] }) {
|
||||
return (
|
||||
<div className="auth-subject-chips">
|
||||
{subjects.map((s, i) => (
|
||||
<span
|
||||
key={`${s.name}-${i}`}
|
||||
className="cap-badge"
|
||||
title={`${s.source_kind}${s.base ? ` (base: ${s.base})` : ''}`}
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useDebugFunctions } from '../../api/queries/debug';
|
||||
import type { FunctionInfo } from '../../api/types';
|
||||
|
||||
|
|
@ -15,9 +16,24 @@ export function FunctionSelector({
|
|||
showFilePath = true,
|
||||
}: Props) {
|
||||
const { data: functions, isLoading } = useDebugFunctions(file || null);
|
||||
const [showClosures, setShowClosures] = useState(false);
|
||||
|
||||
const closureCount = useMemo(
|
||||
() => functions?.filter((fn) => fn.func_kind === 'closure').length ?? 0,
|
||||
[functions],
|
||||
);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!functions) return functions;
|
||||
return showClosures
|
||||
? functions
|
||||
: functions.filter((fn) => fn.func_kind !== 'closure');
|
||||
}, [functions, showClosures]);
|
||||
|
||||
return (
|
||||
<div className="function-selector">
|
||||
<div
|
||||
className={`function-selector${showFilePath ? '' : ' function-selector-flat'}`}
|
||||
>
|
||||
{showFilePath && (
|
||||
<div className="function-selector-path">
|
||||
<span className="function-selector-path-label">File:</span>
|
||||
|
|
@ -31,19 +47,19 @@ export function FunctionSelector({
|
|||
<select
|
||||
value={selectedFunction ?? ''}
|
||||
onChange={(e) => onFunctionChange(e.target.value || null)}
|
||||
disabled={!functions || functions.length === 0}
|
||||
disabled={!visible || visible.length === 0}
|
||||
className="function-selector-select"
|
||||
>
|
||||
<option value="">
|
||||
{isLoading
|
||||
? 'Loading...'
|
||||
: !functions || functions.length === 0
|
||||
: !visible || visible.length === 0
|
||||
? 'No functions found'
|
||||
: 'Select function'}
|
||||
</option>
|
||||
{functions?.map((fn: FunctionInfo) => (
|
||||
{visible?.map((fn: FunctionInfo) => (
|
||||
<option key={fn.name} value={fn.name}>
|
||||
{fn.name}({fn.param_count} params) — L{fn.line}
|
||||
{formatFunctionLabel(fn)}
|
||||
{fn.source_caps.length > 0 &&
|
||||
` [src: ${fn.source_caps.join(',')}]`}
|
||||
{fn.sink_caps.length > 0 && ` [sink: ${fn.sink_caps.join(',')}]`}
|
||||
|
|
@ -51,6 +67,30 @@ export function FunctionSelector({
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
{closureCount > 0 && (
|
||||
<label className="function-selector-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showClosures}
|
||||
onChange={(e) => setShowClosures(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Show {closureCount} anonymous closure
|
||||
{closureCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFunctionLabel(fn: FunctionInfo): string {
|
||||
const sig = `(${fn.param_count} params) — L${fn.line}`;
|
||||
if (fn.func_kind === 'closure' && fn.container) {
|
||||
return `${fn.name} [closure in ${fn.container}] ${sig}`;
|
||||
}
|
||||
if (fn.func_kind === 'closure') {
|
||||
return `${fn.name} [closure] ${sig}`;
|
||||
}
|
||||
return `${fn.name}${sig}`;
|
||||
}
|
||||
|
|
|
|||
200
frontend/src/pages/debug/PointerViewerPage.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useDebugPointer } from '../../api/queries/debug';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { ErrorState } from '../../components/ui/ErrorState';
|
||||
import { LoadingState } from '../../components/ui/LoadingState';
|
||||
import type {
|
||||
PointerLocationView,
|
||||
PointerValueView,
|
||||
PointerFieldEntryView,
|
||||
} from '../../api/types';
|
||||
|
||||
interface PointerAnalysisPanelProps {
|
||||
file: string;
|
||||
functionName: string;
|
||||
}
|
||||
|
||||
export function PointerAnalysisPanel({
|
||||
file,
|
||||
functionName,
|
||||
}: PointerAnalysisPanelProps) {
|
||||
const { data, isLoading, error } = useDebugPointer(file, functionName);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading points-to facts..." />;
|
||||
}
|
||||
if (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return (
|
||||
<EmptyState message="Pointer analysis is not available for the selected function." />
|
||||
);
|
||||
}
|
||||
return <ErrorState message="Failed to load pointer analysis." />;
|
||||
}
|
||||
if (
|
||||
!data ||
|
||||
(data.values.length === 0 &&
|
||||
data.field_reads.length === 0 &&
|
||||
data.field_writes.length === 0)
|
||||
) {
|
||||
return (
|
||||
<EmptyState message="No points-to facts were derived for this function. Pointer analysis flags up parameters, allocation sites, and field projections; functions that only manipulate scalars will appear empty." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="abstract-interp-viewer">
|
||||
<div className="abstract-block">
|
||||
<div className="abstract-block-header">
|
||||
<h3 style={{ margin: 0 }}>Per-Value Points-To</h3>
|
||||
<span className="text-secondary">
|
||||
{data.values.length} value
|
||||
{data.values.length === 1 ? '' : 's'} · {data.location_count}{' '}
|
||||
location
|
||||
{data.location_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{data.values.length === 0 ? (
|
||||
<p className="abstract-empty">
|
||||
All SSA values point to nothing tracked.
|
||||
</p>
|
||||
) : (
|
||||
<PointerValueTable values={data.values} locations={data.locations} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.field_reads.length > 0 && (
|
||||
<FieldEntriesBlock
|
||||
title="Field Reads"
|
||||
entries={data.field_reads}
|
||||
emptyHint="(no parameter field reads recorded)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.field_writes.length > 0 && (
|
||||
<FieldEntriesBlock
|
||||
title="Field Writes"
|
||||
entries={data.field_writes}
|
||||
emptyHint="(no parameter field writes recorded)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PointerValueTable({
|
||||
values,
|
||||
locations,
|
||||
}: {
|
||||
values: PointerValueView[];
|
||||
locations: PointerLocationView[];
|
||||
}) {
|
||||
const locById = useMemo(() => {
|
||||
const map = new Map<number, PointerLocationView>();
|
||||
for (const loc of locations) map.set(loc.id, loc);
|
||||
return map;
|
||||
}, [locations]);
|
||||
|
||||
return (
|
||||
<table className="abstract-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Value</th>
|
||||
<th>Name</th>
|
||||
<th>Points-To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{values.map((v) => (
|
||||
<tr key={v.ssa_value}>
|
||||
<td className="mono">v{v.ssa_value}</td>
|
||||
<td className="mono">{v.var_name ?? '-'}</td>
|
||||
<td>
|
||||
{v.is_top ? (
|
||||
<span
|
||||
className="cap-badge cap-badge-sink"
|
||||
title="Over-approximation"
|
||||
>
|
||||
⊤ (top)
|
||||
</span>
|
||||
) : (
|
||||
v.points_to.map((id) => (
|
||||
<LocationChip key={id} loc={locById.get(id)} />
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationChip({ loc }: { loc?: PointerLocationView }) {
|
||||
if (!loc) {
|
||||
return (
|
||||
<span className="cap-badge" title="Unknown location id">
|
||||
?
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const className =
|
||||
loc.kind === 'Top'
|
||||
? 'cap-badge cap-badge-sink'
|
||||
: loc.kind === 'Field'
|
||||
? 'cap-badge cap-badge-sanitizer'
|
||||
: 'cap-badge cap-badge-source';
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
title={`${loc.kind} (loc#${loc.id})`}
|
||||
style={{ marginRight: 4 }}
|
||||
>
|
||||
{loc.display}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldEntriesBlock({
|
||||
title,
|
||||
entries,
|
||||
emptyHint,
|
||||
}: {
|
||||
title: string;
|
||||
entries: PointerFieldEntryView[];
|
||||
emptyHint: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="abstract-block">
|
||||
<div className="abstract-block-header">
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
<span className="text-secondary">
|
||||
{entries.length} entr{entries.length === 1 ? 'y' : 'ies'}
|
||||
</span>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<p className="abstract-empty">{emptyHint}</p>
|
||||
) : (
|
||||
<table className="abstract-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
<th>Field</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e, i) => (
|
||||
<tr key={`${e.param_index ?? 'self'}-${e.field}-${i}`}>
|
||||
<td className="mono">
|
||||
{e.param_index === null ? 'self' : `param[${e.param_index}]`}
|
||||
</td>
|
||||
<td className="mono">{e.field}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDebugSummaries } from '../../api/queries/debug';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
|
|
@ -22,6 +22,17 @@ export function SummaryAnalysisPanel({
|
|||
scope === 'global' ? null : (functionName ?? null),
|
||||
);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [showClosures, setShowClosures] = useState(false);
|
||||
|
||||
const closureCount = useMemo(
|
||||
() => data?.filter((s) => s.func_kind === 'closure').length ?? 0,
|
||||
[data],
|
||||
);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!data) return data;
|
||||
return showClosures ? data : data.filter((s) => s.func_kind !== 'closure');
|
||||
}, [data, showClosures]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading summaries..." />;
|
||||
|
|
@ -48,15 +59,32 @@ export function SummaryAnalysisPanel({
|
|||
);
|
||||
}
|
||||
|
||||
const visibleCount = visible?.length ?? 0;
|
||||
const totalCount = data.length;
|
||||
|
||||
return (
|
||||
<div className="summary-explorer">
|
||||
<div className="summary-header">
|
||||
<span className="text-secondary">
|
||||
{data.length}{' '}
|
||||
{visibleCount}
|
||||
{visibleCount !== totalCount && ` of ${totalCount}`}{' '}
|
||||
{scope === 'global'
|
||||
? 'functions across the project'
|
||||
: 'functions in this file'}
|
||||
</span>
|
||||
{closureCount > 0 && (
|
||||
<label className="summary-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showClosures}
|
||||
onChange={(e) => setShowClosures(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Show {closureCount} anonymous closure
|
||||
{closureCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<table className="summary-table">
|
||||
<thead>
|
||||
|
|
@ -71,20 +99,19 @@ export function SummaryAnalysisPanel({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((s) => (
|
||||
<SummaryRow
|
||||
key={`${s.namespace}::${s.name}`}
|
||||
summary={s}
|
||||
isExpanded={expanded === `${s.namespace}::${s.name}`}
|
||||
onToggle={() =>
|
||||
setExpanded(
|
||||
expanded === `${s.namespace}::${s.name}`
|
||||
? null
|
||||
: `${s.namespace}::${s.name}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{visible?.map((s) => {
|
||||
const rowKey = `${s.namespace}::${s.container}::${s.name}`;
|
||||
return (
|
||||
<SummaryRow
|
||||
key={rowKey}
|
||||
summary={s}
|
||||
isExpanded={expanded === rowKey}
|
||||
onToggle={() =>
|
||||
setExpanded(expanded === rowKey ? null : rowKey)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -104,10 +131,23 @@ function SummaryRow({
|
|||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const isClosure = summary.func_kind === 'closure';
|
||||
return (
|
||||
<>
|
||||
<tr onClick={onToggle} style={{ cursor: 'pointer' }}>
|
||||
<td className="mono">{summary.name}</td>
|
||||
<td className="mono">
|
||||
{summary.name}
|
||||
{isClosure && (
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ marginLeft: 8, fontSize: '0.85em' }}
|
||||
>
|
||||
{summary.container
|
||||
? `[closure in ${summary.container}]`
|
||||
: '[closure]'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{summary.lang}</td>
|
||||
<td>{summary.param_count}</td>
|
||||
<td>
|
||||
|
|
|
|||
202
frontend/src/pages/debug/TypeFactsPage.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { useDebugTypeFacts } from '../../api/queries/debug';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { ErrorState } from '../../components/ui/ErrorState';
|
||||
import { LoadingState } from '../../components/ui/LoadingState';
|
||||
import type { TypeFactDetailView, DtoFactView } from '../../api/types';
|
||||
|
||||
interface TypeFactsAnalysisPanelProps {
|
||||
file: string;
|
||||
functionName: string;
|
||||
}
|
||||
|
||||
const SECURITY_TYPES = new Set([
|
||||
'HttpClient',
|
||||
'HttpResponse',
|
||||
'DatabaseConnection',
|
||||
'FileHandle',
|
||||
'Url',
|
||||
'LocalCollection',
|
||||
]);
|
||||
|
||||
export function TypeFactsAnalysisPanel({
|
||||
file,
|
||||
functionName,
|
||||
}: TypeFactsAnalysisPanelProps) {
|
||||
const { data, isLoading, error } = useDebugTypeFacts(file, functionName);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading type facts..." />;
|
||||
}
|
||||
if (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return (
|
||||
<EmptyState message="Type facts are not available for the selected function." />
|
||||
);
|
||||
}
|
||||
return <ErrorState message="Failed to load type facts." />;
|
||||
}
|
||||
if (!data || data.facts.length === 0) {
|
||||
return (
|
||||
<EmptyState message="No type facts were inferred for this function. Type analysis fires when constructors, framework extractors, or constant literals reveal a value's type." />
|
||||
);
|
||||
}
|
||||
|
||||
const securityFacts = data.facts.filter((f) => SECURITY_TYPES.has(f.kind));
|
||||
const dtoFacts = data.facts.filter((f) => f.kind === 'Dto');
|
||||
const scalarFacts = data.facts.filter(
|
||||
(f) => !SECURITY_TYPES.has(f.kind) && f.kind !== 'Dto',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="abstract-interp-viewer">
|
||||
<div className="abstract-block">
|
||||
<div className="abstract-block-header">
|
||||
<h3 style={{ margin: 0 }}>Inferred Types</h3>
|
||||
<span className="text-secondary">
|
||||
{data.facts.length} of {data.total_values} SSA values typed ·{' '}
|
||||
{data.unknown_count} unknown
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{securityFacts.length > 0 && (
|
||||
<TypeFactGroup
|
||||
title="Security-Relevant Types"
|
||||
subtitle="HttpClient, DatabaseConnection, Url, … — drive type-qualified callee resolution and sink suppression"
|
||||
facts={securityFacts}
|
||||
highlight
|
||||
/>
|
||||
)}
|
||||
|
||||
{dtoFacts.length > 0 && (
|
||||
<DtoFactGroup
|
||||
title="DTO Types"
|
||||
subtitle="Framework-injected DTO bodies with known field shapes (Phase 6)"
|
||||
facts={dtoFacts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scalarFacts.length > 0 && (
|
||||
<TypeFactGroup
|
||||
title="Scalar Types"
|
||||
subtitle="String / Int / Bool / Object / Array / Null inferences"
|
||||
facts={scalarFacts}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeFactGroup({
|
||||
title,
|
||||
subtitle,
|
||||
facts,
|
||||
highlight,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
facts: TypeFactDetailView[];
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="abstract-block">
|
||||
<div className="abstract-block-header">
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
<span className="text-secondary">
|
||||
{facts.length} value{facts.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{subtitle && <p className="abstract-subtitle">{subtitle}</p>}
|
||||
<table className="abstract-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Value</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Container</th>
|
||||
<th>Nullable</th>
|
||||
<th>Line</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{facts.map((f) => (
|
||||
<tr key={f.ssa_value}>
|
||||
<td className="mono">v{f.ssa_value}</td>
|
||||
<td className="mono">{f.var_name ?? '-'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`cap-badge ${
|
||||
highlight ? 'cap-badge-sink' : 'cap-badge-source'
|
||||
}`}
|
||||
>
|
||||
{f.kind}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono">{f.container ?? '-'}</td>
|
||||
<td>{f.nullable ? 'Yes' : 'No'}</td>
|
||||
<td className="mono">{f.line > 0 ? `L${f.line}` : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DtoFactGroup({
|
||||
title,
|
||||
subtitle,
|
||||
facts,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
facts: TypeFactDetailView[];
|
||||
}) {
|
||||
return (
|
||||
<div className="abstract-block">
|
||||
<div className="abstract-block-header">
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
<span className="text-secondary">
|
||||
{facts.length} DTO{facts.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{subtitle && <p className="abstract-subtitle">{subtitle}</p>}
|
||||
{facts.map((f) => (
|
||||
<div key={f.ssa_value} style={{ padding: '8px 12px' }}>
|
||||
<div className="debug-detail-row">
|
||||
<span className="debug-detail-label">DTO</span>
|
||||
<span className="debug-detail-value mono">
|
||||
v{f.ssa_value} {f.var_name ? `(${f.var_name}) ` : ''}:{' '}
|
||||
{f.dto?.class_name ?? '?'}
|
||||
</span>
|
||||
</div>
|
||||
{f.dto && f.dto.fields.length > 0 && <DtoFieldTable dto={f.dto} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DtoFieldTable({ dto }: { dto: DtoFactView }) {
|
||||
return (
|
||||
<table className="abstract-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Kind</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dto.fields.map((f) => (
|
||||
<tr key={f.name}>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td>
|
||||
<span className="cap-badge cap-badge-source">{f.kind}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
|
@ -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/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/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/statcard.tsx","./src/contexts/ssecontext.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/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.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/stubpage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.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":"5.6.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/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":"5.6.3"}
|
||||
2391
fuzz/Cargo.lock
generated
Normal file
33
fuzz/Cargo.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
[package]
|
||||
name = "nyx-fuzz"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
nyx-scanner = { path = ".." }
|
||||
|
||||
[[bin]]
|
||||
name = "scan_bytes"
|
||||
path = "fuzz_targets/scan_bytes.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "extract_summaries"
|
||||
path = "fuzz_targets/extract_summaries.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "cross_file_taint"
|
||||
path = "fuzz_targets/cross_file_taint.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
253
fuzz/dict/all.dict
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# libFuzzer dictionary for the Nyx fuzz targets.
|
||||
#
|
||||
# Each entry is a quoted string libFuzzer can splice into mutations. We bias
|
||||
# toward tokens that unlock new tree-sitter / CFG / taint paths across the
|
||||
# 10 supported languages, plus the synthetic helper names registered by
|
||||
# `cross_file_taint` so call-site mutations resolve against `GlobalSummaries`
|
||||
# instead of bouncing off as unknown calls.
|
||||
#
|
||||
# Format: one entry per line, `name="..."` or `"..."`. Lines starting with
|
||||
# `#` are comments. C-style escapes (`\xNN`, `\n`, `\\`, `\"`) are honored.
|
||||
|
||||
# ── Punctuation / structural tokens ────────────────────────────────────
|
||||
"{"
|
||||
"}"
|
||||
"("
|
||||
")"
|
||||
"["
|
||||
"]"
|
||||
";"
|
||||
","
|
||||
"."
|
||||
"::"
|
||||
"->"
|
||||
"=>"
|
||||
":="
|
||||
":"
|
||||
"="
|
||||
"=="
|
||||
"!="
|
||||
"<="
|
||||
">="
|
||||
"&&"
|
||||
"||"
|
||||
"+"
|
||||
"-"
|
||||
"*"
|
||||
"/"
|
||||
"%"
|
||||
"<"
|
||||
">"
|
||||
"!"
|
||||
"&"
|
||||
"|"
|
||||
"^"
|
||||
"~"
|
||||
"?"
|
||||
"#"
|
||||
"@"
|
||||
|
||||
# ── Cross-language keywords ────────────────────────────────────────────
|
||||
"if"
|
||||
"else"
|
||||
"elif"
|
||||
"while"
|
||||
"for"
|
||||
"do"
|
||||
"return"
|
||||
"break"
|
||||
"continue"
|
||||
"switch"
|
||||
"case"
|
||||
"default"
|
||||
"true"
|
||||
"false"
|
||||
"null"
|
||||
"nil"
|
||||
"None"
|
||||
"undefined"
|
||||
"void"
|
||||
"int"
|
||||
"float"
|
||||
"double"
|
||||
"char"
|
||||
"bool"
|
||||
"string"
|
||||
"var"
|
||||
"let"
|
||||
"const"
|
||||
"static"
|
||||
"public"
|
||||
"private"
|
||||
"protected"
|
||||
"new"
|
||||
"this"
|
||||
"self"
|
||||
"super"
|
||||
"class"
|
||||
"struct"
|
||||
"enum"
|
||||
"interface"
|
||||
"trait"
|
||||
"impl"
|
||||
"module"
|
||||
"package"
|
||||
"import"
|
||||
"from"
|
||||
"use"
|
||||
"as"
|
||||
"function"
|
||||
"def"
|
||||
"fn"
|
||||
"func"
|
||||
"sub"
|
||||
"end"
|
||||
"begin"
|
||||
"try"
|
||||
"catch"
|
||||
"except"
|
||||
"finally"
|
||||
"raise"
|
||||
"throw"
|
||||
"throws"
|
||||
"async"
|
||||
"await"
|
||||
"yield"
|
||||
"lambda"
|
||||
"match"
|
||||
"with"
|
||||
"in"
|
||||
"of"
|
||||
"is"
|
||||
"not"
|
||||
"and"
|
||||
"or"
|
||||
|
||||
# ── Common literals / format strings ───────────────────────────────────
|
||||
"\"\""
|
||||
"\"x\""
|
||||
"\"%s\""
|
||||
"\"%d\""
|
||||
"\"%v\""
|
||||
"\"{}\""
|
||||
"`x`"
|
||||
"'x'"
|
||||
"0"
|
||||
"1"
|
||||
"-1"
|
||||
"0x0"
|
||||
"0xff"
|
||||
|
||||
# ── Security-flavored function names (sources, sinks, sanitizers) ──────
|
||||
"exec"
|
||||
"eval"
|
||||
"system"
|
||||
"popen"
|
||||
"shell_exec"
|
||||
"passthru"
|
||||
"spawn"
|
||||
"execSync"
|
||||
"execFile"
|
||||
"Runtime.getRuntime"
|
||||
"Process"
|
||||
"Command"
|
||||
"query"
|
||||
"execute"
|
||||
"executeQuery"
|
||||
"prepare"
|
||||
"raw_query"
|
||||
"mysql_query"
|
||||
"mysqli_query"
|
||||
"pg_query"
|
||||
"sqlite_query"
|
||||
"unserialize"
|
||||
"pickle.loads"
|
||||
"yaml.load"
|
||||
"json.loads"
|
||||
"readObject"
|
||||
"deserialize"
|
||||
"escape"
|
||||
"escapeshellarg"
|
||||
"escapeshellcmd"
|
||||
"htmlspecialchars"
|
||||
"htmlentities"
|
||||
"escape_html"
|
||||
"sanitize"
|
||||
"strip_tags"
|
||||
"prepareStatement"
|
||||
"PreparedStatement"
|
||||
"parseFromString"
|
||||
"setAttribute"
|
||||
"innerHTML"
|
||||
"document.write"
|
||||
"window.location"
|
||||
"location.href"
|
||||
|
||||
# ── Sources (taint origins) ────────────────────────────────────────────
|
||||
"req.body"
|
||||
"req.query"
|
||||
"req.params"
|
||||
"request.GET"
|
||||
"request.POST"
|
||||
"request.args"
|
||||
"request.form"
|
||||
"$_GET"
|
||||
"$_POST"
|
||||
"$_REQUEST"
|
||||
"$_COOKIE"
|
||||
"params"
|
||||
"argv"
|
||||
"stdin"
|
||||
"getenv"
|
||||
"env::var"
|
||||
"os.environ"
|
||||
"ENV"
|
||||
"Console.ReadLine"
|
||||
"input"
|
||||
"raw_input"
|
||||
"fgets"
|
||||
"scanf"
|
||||
"gets"
|
||||
"http.Get"
|
||||
"http.Post"
|
||||
"reqwest::get"
|
||||
"fetch"
|
||||
"axios.get"
|
||||
"file_get_contents"
|
||||
"readFileSync"
|
||||
|
||||
# ── Common injection payload markers ───────────────────────────────────
|
||||
"<script>"
|
||||
"</script>"
|
||||
"javascript:"
|
||||
"onerror="
|
||||
"onload="
|
||||
"' OR '1'='1"
|
||||
"'; DROP TABLE"
|
||||
"UNION SELECT"
|
||||
"--"
|
||||
"/*"
|
||||
"*/"
|
||||
"../"
|
||||
"..\\\\"
|
||||
"/etc/passwd"
|
||||
"file://"
|
||||
"http://169.254.169.254"
|
||||
"ldap://"
|
||||
|
||||
# ── Synthetic helpers used by `cross_file_taint` ───────────────────────
|
||||
"nyx_taint_source"
|
||||
"nyx_sanitize"
|
||||
"nyx_dangerous_sink"
|
||||
"nyx_pass_through"
|
||||
|
||||
# ── Tricky parser edge cases ───────────────────────────────────────────
|
||||
"\"\\xff\\xff\""
|
||||
"\"\\u0000\""
|
||||
"\"\\n\\r\\t\""
|
||||
"\"\\xc3\\x28\""
|
||||
"<?php"
|
||||
"?>"
|
||||
"<?xml"
|
||||
"#!/bin/sh"
|
||||
"\"\\\\\""
|
||||
146
fuzz/fuzz_targets/cross_file_taint.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
#![no_main]
|
||||
|
||||
// Cross-file resolution path: drives `run_rules_on_bytes` with a
|
||||
// pre-seeded `GlobalSummaries` so the SSA/taint engine actually
|
||||
// exercises `resolve_callee` against external summaries instead of
|
||||
// short-circuiting on `None` like `scan_bytes` does. The synthetic
|
||||
// summaries register one source / sanitizer / sink / pass-through
|
||||
// helper per language under fixed names, so libFuzzer mutations that
|
||||
// produce calls to those names hit the cross-file merge + resolution
|
||||
// paths (`GlobalSummaries::insert`, `by_lang_name` / `by_lang_qualified`
|
||||
// lookups, `ssa_by_key` precedence). The dictionary committed alongside
|
||||
// this target lists those names so libFuzzer biases towards them.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use nyx_scanner::ast::run_rules_on_bytes;
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::summary::{FuncSummary, GlobalSummaries};
|
||||
use nyx_scanner::symbol::{FuncKey, Lang};
|
||||
use nyx_scanner::utils::config::Config;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const EXTENSIONS: &[&str] = &[
|
||||
"rs", "js", "ts", "py", "go", "java", "rb", "php", "c", "cpp",
|
||||
];
|
||||
|
||||
const LANGS: &[Lang] = &[
|
||||
Lang::Rust,
|
||||
Lang::JavaScript,
|
||||
Lang::TypeScript,
|
||||
Lang::Python,
|
||||
Lang::Go,
|
||||
Lang::Java,
|
||||
Lang::Ruby,
|
||||
Lang::Php,
|
||||
Lang::C,
|
||||
Lang::Cpp,
|
||||
];
|
||||
|
||||
// Helper names registered in `GlobalSummaries`. The dictionary file
|
||||
// (`fuzz/dict/all.dict`) lists these so libFuzzer mutations bias
|
||||
// toward producing calls that resolve to them.
|
||||
const SYNTHETIC_HELPERS: &[(&str, HelperRole)] = &[
|
||||
("nyx_taint_source", HelperRole::Source),
|
||||
("nyx_sanitize", HelperRole::Sanitizer),
|
||||
("nyx_dangerous_sink", HelperRole::Sink),
|
||||
("nyx_pass_through", HelperRole::PassThrough),
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum HelperRole {
|
||||
Source,
|
||||
Sanitizer,
|
||||
Sink,
|
||||
PassThrough,
|
||||
}
|
||||
|
||||
fn build_global_summaries() -> GlobalSummaries {
|
||||
let mut g = GlobalSummaries::new();
|
||||
for &lang in LANGS {
|
||||
for &(name, role) in SYNTHETIC_HELPERS {
|
||||
let arity = match role {
|
||||
HelperRole::Source => 0,
|
||||
HelperRole::Sanitizer | HelperRole::Sink | HelperRole::PassThrough => 1,
|
||||
};
|
||||
let key = FuncKey {
|
||||
lang,
|
||||
namespace: format!("nyx_synthetic_{}.{}", lang.as_str(), default_ext(lang)),
|
||||
name: name.into(),
|
||||
arity: Some(arity),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = match role {
|
||||
HelperRole::Source => FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: key.namespace.clone(),
|
||||
lang: lang.as_str().into(),
|
||||
param_count: 0,
|
||||
param_names: vec![],
|
||||
source_caps: Cap::all().bits(),
|
||||
..Default::default()
|
||||
},
|
||||
HelperRole::Sanitizer => FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: key.namespace.clone(),
|
||||
lang: lang.as_str().into(),
|
||||
param_count: 1,
|
||||
param_names: vec!["input".into()],
|
||||
sanitizer_caps: Cap::all().bits(),
|
||||
propagating_params: vec![0],
|
||||
..Default::default()
|
||||
},
|
||||
HelperRole::Sink => FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: key.namespace.clone(),
|
||||
lang: lang.as_str().into(),
|
||||
param_count: 1,
|
||||
param_names: vec!["input".into()],
|
||||
sink_caps: Cap::all().bits(),
|
||||
tainted_sink_params: vec![0],
|
||||
..Default::default()
|
||||
},
|
||||
HelperRole::PassThrough => FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: key.namespace.clone(),
|
||||
lang: lang.as_str().into(),
|
||||
param_count: 1,
|
||||
param_names: vec!["input".into()],
|
||||
propagating_params: vec![0],
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
g.insert(key, summary);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
fn default_ext(lang: Lang) -> &'static str {
|
||||
match lang {
|
||||
Lang::Rust => "rs",
|
||||
Lang::JavaScript => "js",
|
||||
Lang::TypeScript => "ts",
|
||||
Lang::Python => "py",
|
||||
Lang::Go => "go",
|
||||
Lang::Java => "java",
|
||||
Lang::Ruby => "rb",
|
||||
Lang::Php => "php",
|
||||
Lang::C => "c",
|
||||
Lang::Cpp => "cpp",
|
||||
}
|
||||
}
|
||||
|
||||
static GLOBAL: OnceLock<GlobalSummaries> = OnceLock::new();
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ext = EXTENSIONS[(data[0] as usize) % EXTENSIONS.len()];
|
||||
let path_buf = format!("fuzz_input.{ext}");
|
||||
let path = Path::new(&path_buf);
|
||||
let cfg = Config::default();
|
||||
let summaries = GLOBAL.get_or_init(build_global_summaries);
|
||||
let _ = run_rules_on_bytes(&data[1..], path, &cfg, Some(summaries), None);
|
||||
});
|
||||
25
fuzz/fuzz_targets/extract_summaries.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#![no_main]
|
||||
|
||||
// Pass-1 of the two-pass scanner: parse + summary extraction only,
|
||||
// without taint, rules, or cross-file resolution. Smaller surface than
|
||||
// `scan_bytes`, so libFuzzer converges on parse / lowering bugs faster
|
||||
// when they exist.
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use nyx_scanner::ast::extract_summaries_from_bytes;
|
||||
use nyx_scanner::utils::config::Config;
|
||||
use std::path::Path;
|
||||
|
||||
const EXTENSIONS: &[&str] = &[
|
||||
"rs", "js", "ts", "py", "go", "java", "rb", "php", "c", "cpp",
|
||||
];
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ext = EXTENSIONS[(data[0] as usize) % EXTENSIONS.len()];
|
||||
let path_buf = format!("fuzz_input.{ext}");
|
||||
let path = Path::new(&path_buf);
|
||||
let cfg = Config::default();
|
||||
let _ = extract_summaries_from_bytes(&data[1..], path, &cfg);
|
||||
});
|
||||
25
fuzz/fuzz_targets/scan_bytes.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use nyx_scanner::ast::run_rules_on_bytes;
|
||||
use nyx_scanner::utils::config::Config;
|
||||
use std::path::Path;
|
||||
|
||||
// One extension per supported tree-sitter grammar. The first input byte
|
||||
// picks which language path the parser takes; the rest is fed in as
|
||||
// source. Splitting this way lets a single corpus exercise all 10
|
||||
// language frontends without separate fuzz targets.
|
||||
const EXTENSIONS: &[&str] = &[
|
||||
"rs", "js", "ts", "py", "go", "java", "rb", "php", "c", "cpp",
|
||||
];
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ext = EXTENSIONS[(data[0] as usize) % EXTENSIONS.len()];
|
||||
let path_buf = format!("fuzz_input.{ext}");
|
||||
let path = Path::new(&path_buf);
|
||||
let cfg = Config::default();
|
||||
let _ = run_rules_on_bytes(&data[1..], path, &cfg, None, None);
|
||||
});
|
||||
8
fuzz/seed_corpus/cross_file_taint/xfile_c.c
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include <stdio.h>
|
||||
int main(void) {
|
||||
char *x = nyx_taint_source();
|
||||
nyx_dangerous_sink(x);
|
||||
char *y = nyx_sanitize(nyx_taint_source());
|
||||
nyx_dangerous_sink(y);
|
||||
return 0;
|
||||
}
|
||||
8
fuzz/seed_corpus/cross_file_taint/xfile_cpp.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include <string>
|
||||
int main() {
|
||||
std::string x = nyx_taint_source();
|
||||
nyx_dangerous_sink(x);
|
||||
std::string y = nyx_sanitize(nyx_taint_source());
|
||||
nyx_dangerous_sink(y);
|
||||
return 0;
|
||||
}
|
||||