diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..7a48f23d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @elicpeter diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..b6cb49a9 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: elicpeter diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..ec73b55b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..18ae00e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..10897d6b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..bf15c9bc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## Summary + + + +## Related issues + + + +## 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 + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..bcbfbe3f --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 607f5d87..003a7edd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4810eac3..5a7ea414 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -6,7 +6,7 @@ on: pull_request: branches: ["master"] schedule: - - cron: "28 20 * * 2" + - cron: "0 9 * * 2" jobs: analyze: diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 00000000..170c8012 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -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 }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 60cbcbd9..fa5e86a2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..ad4fd326 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -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 , 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 diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index f6ecc322..42b302cc 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -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 .pem \ + # --signature .sig \ + # --certificate-identity-regexp 'https://github.com/elicpeter/nyx/.*' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + # + - 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 --repo `. + # SLSA v1 provenance. Verify with `gh attestation verify --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 }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..42757097 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -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 diff --git a/.gitignore b/.gitignore index c7f2d98e..37ea5d83 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.nyx/triage.json b/.nyx/triage.json deleted file mode 100644 index e63bca2a..00000000 --- a/.nyx/triage.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "decisions": [], - "suppression_rules": [] -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ace941..fb6a1551 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/CLA.md b/CLA.md index eb0f9f73..205aaa1d 100644 --- a/CLA.md +++ b/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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81645a9d..e64583c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) - 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/.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. --- diff --git a/Cargo.lock b/Cargo.lock index c3d1dba9..3b65d505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 0faa126d..6fcda356 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,39 +8,36 @@ license = "GPL-3.0-or-later" authors = ["Eli Peter "] 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" diff --git a/README.md b/README.md index 1a05a787..a1e2b018 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Rust 1.88+](https://img.shields.io/badge/rust-1.88%2B-orange)](https://www.rust-lang.org) [![CI](https://img.shields.io/github/actions/workflow/status/elicpeter/nyx/ci.yml?branch=master)](https://github.com/elicpeter/nyx/actions) +[![Docs](https://img.shields.io/badge/docs-elicpeter.github.io%2Fnyx-blue)](https://elicpeter.github.io/nyx/) -

Nyx UI walkthrough: scan, browse findings, inspect flow path, triage

+

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

--- @@ -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. -

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

+

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

--- @@ -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 ` (default `9700`), `--host ` (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 ` (default `9700`), `--host ` (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) --- diff --git a/ROADMAP.md b/ROADMAP.md index 94137664..aa2b8395 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | | --- | --- | diff --git a/SECURITY.md b/SECURITY.md index e730bacc..dfe222b3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/THIRDPARTY-LICENSES.html b/THIRDPARTY-LICENSES.html index 84ca7a8c..ed3ef23a 100644 --- a/THIRDPARTY-LICENSES.html +++ b/THIRDPARTY-LICENSES.html @@ -45,7 +45,7 @@

Overview of licenses:

  • Apache License 2.0 (159)
  • -
  • MIT License (69)
  • +
  • MIT License (71)
  • zlib License (2)
  • BSD 2-Clause "Simplified" License (1)
  • BSD 3-Clause "New" or "Revised" License (1)
  • @@ -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. + + +
  • +

    MIT License

    +

    Used by:

    + +
    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
    diff --git a/assets/screenshots/cli-scan.png b/assets/screenshots/cli-scan.png
    index 0c030d6f..a30e7771 100644
    Binary files a/assets/screenshots/cli-scan.png and b/assets/screenshots/cli-scan.png differ
    diff --git a/assets/screenshots/demo.gif b/assets/screenshots/demo.gif
    index 14fc2e55..689c7b64 100644
    Binary files a/assets/screenshots/demo.gif and b/assets/screenshots/demo.gif differ
    diff --git a/assets/screenshots/docs/cli-configshow.png b/assets/screenshots/docs/cli-configshow.png
    index 7949dd62..f75d2c2b 100644
    Binary files a/assets/screenshots/docs/cli-configshow.png and b/assets/screenshots/docs/cli-configshow.png differ
    diff --git a/assets/screenshots/docs/cli-explain-engine.png b/assets/screenshots/docs/cli-explain-engine.png
    index a8ac4419..5483463a 100644
    Binary files a/assets/screenshots/docs/cli-explain-engine.png and b/assets/screenshots/docs/cli-explain-engine.png differ
    diff --git a/assets/screenshots/docs/cli-failon.png b/assets/screenshots/docs/cli-failon.png
    index 087c4916..a30e7771 100644
    Binary files a/assets/screenshots/docs/cli-failon.png and b/assets/screenshots/docs/cli-failon.png differ
    diff --git a/assets/screenshots/docs/cli-idxstatus.png b/assets/screenshots/docs/cli-idxstatus.png
    index 338b5871..a548b773 100644
    Binary files a/assets/screenshots/docs/cli-idxstatus.png and b/assets/screenshots/docs/cli-idxstatus.png differ
    diff --git a/assets/screenshots/docs/cli-scan-quickstart.png b/assets/screenshots/docs/cli-scan-quickstart.png
    deleted file mode 100644
    index ae189dea..00000000
    Binary files a/assets/screenshots/docs/cli-scan-quickstart.png and /dev/null differ
    diff --git a/assets/screenshots/docs/serve-config.png b/assets/screenshots/docs/serve-config.png
    index 6d918741..2ccd2447 100644
    Binary files a/assets/screenshots/docs/serve-config.png and b/assets/screenshots/docs/serve-config.png differ
    diff --git a/assets/screenshots/docs/serve-explorer.png b/assets/screenshots/docs/serve-explorer.png
    index 6a83d99f..11c1d7ac 100644
    Binary files a/assets/screenshots/docs/serve-explorer.png and b/assets/screenshots/docs/serve-explorer.png differ
    diff --git a/assets/screenshots/docs/serve-finding-detail.png b/assets/screenshots/docs/serve-finding-detail.png
    index 169815cd..297f42b4 100644
    Binary files a/assets/screenshots/docs/serve-finding-detail.png and b/assets/screenshots/docs/serve-finding-detail.png differ
    diff --git a/assets/screenshots/docs/serve-findings-list.png b/assets/screenshots/docs/serve-findings-list.png
    index a7722f73..2fa5ceb1 100644
    Binary files a/assets/screenshots/docs/serve-findings-list.png and b/assets/screenshots/docs/serve-findings-list.png differ
    diff --git a/assets/screenshots/docs/serve-overview.png b/assets/screenshots/docs/serve-overview.png
    index 70690ad8..e978649a 100644
    Binary files a/assets/screenshots/docs/serve-overview.png and b/assets/screenshots/docs/serve-overview.png differ
    diff --git a/assets/screenshots/docs/serve-rules.png b/assets/screenshots/docs/serve-rules.png
    index b52fcb22..03559acd 100644
    Binary files a/assets/screenshots/docs/serve-rules.png and b/assets/screenshots/docs/serve-rules.png differ
    diff --git a/assets/screenshots/docs/serve-scan-detail.png b/assets/screenshots/docs/serve-scan-detail.png
    index 7f6d7abf..affb7d29 100644
    Binary files a/assets/screenshots/docs/serve-scan-detail.png and b/assets/screenshots/docs/serve-scan-detail.png differ
    diff --git a/assets/screenshots/docs/serve-scans.png b/assets/screenshots/docs/serve-scans.png
    index 524406c1..c045766d 100644
    Binary files a/assets/screenshots/docs/serve-scans.png and b/assets/screenshots/docs/serve-scans.png differ
    diff --git a/assets/screenshots/docs/serve-triage.png b/assets/screenshots/docs/serve-triage.png
    index 5fb3471b..643061fe 100644
    Binary files a/assets/screenshots/docs/serve-triage.png and b/assets/screenshots/docs/serve-triage.png differ
    diff --git a/assets/screenshots/overview.png b/assets/screenshots/overview.png
    index 9dbb739d..e978649a 100644
    Binary files a/assets/screenshots/overview.png and b/assets/screenshots/overview.png differ
    diff --git a/benches/scan_bench.rs b/benches/scan_bench.rs
    index d11f1fac..472505f4 100644
    --- a/benches/scan_bench.rs
    +++ b/benches/scan_bench.rs
    @@ -156,6 +156,7 @@ fn bench_state_analysis_only(c: &mut Criterion) {
                     &[],
                     &[],
                     &std::collections::HashSet::new(),
    +                None,
                 )
             });
         });
    diff --git a/docs/advanced-analysis.md b/docs/advanced-analysis.md
    index 0444007c..b648265b 100644
    --- a/docs/advanced-analysis.md
    +++ b/docs/advanced-analysis.md
    @@ -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,
    diff --git a/docs/detectors/taint.md b/docs/detectors/taint.md
    index 473af0e6..2c436e05 100644
    --- a/docs/detectors/taint.md
    +++ b/docs/detectors/taint.md
    @@ -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` of port numbers and a `vector` 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 |
     
    diff --git a/docs/how-it-works.md b/docs/how-it-works.md
    index 42c6b329..f1c68001 100644
    --- a/docs/how-it-works.md
    +++ b/docs/how-it-works.md
    @@ -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 |
    diff --git a/docs/language-maturity.md b/docs/language-maturity.md
    index 2a6c15d1..ee2eae8a 100644
    --- a/docs/language-maturity.md
    +++ b/docs/language-maturity.md
    @@ -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/.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/.rs`; benchmark cases live at
       `tests/benchmark/corpus//`.
    -- **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
    diff --git a/docs/quickstart.md b/docs/quickstart.md
    index 267ea8c0..d1858910 100644
    --- a/docs/quickstart.md
    +++ b/docs/quickstart.md
    @@ -10,7 +10,7 @@ First run builds a SQLite index under `.nyx/`; later runs skip files whose conte
     
     ## What a finding looks like
     
    -

    nyx scan output: two HIGH taint flows (Python os.system, JavaScript document.write) framed by the brand purple gradient

    +

    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

    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. diff --git a/docs/serve.md b/docs/serve.md index a2afd4d3..24d59bd7 100644 --- a/docs/serve.md +++ b/docs/serve.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed0cd3c0..0611e6de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - - - - - - + + + + + + + + + + + + ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 365a1bf1..df5ebe83 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3,13 +3,57 @@ const CSRF_HEADER = 'X-Nyx-CSRF'; let csrfTokenPromise: Promise | 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_` 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 { + 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 { @@ -17,10 +61,7 @@ async function getCsrfToken(): Promise { 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 { 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(path: string, opts: RequestInit = {}): Promise { 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( }); } -export function apiDelete(path: string, signal?: AbortSignal): Promise { - return request(path, { method: 'DELETE', signal }); +export function apiPut( + path: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + return request(path, { + method: 'PUT', + body: body != null ? JSON.stringify(body) : undefined, + signal, + }); +} + +export function apiDelete( + path: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + return request(path, { + method: 'DELETE', + body: body != null ? JSON.stringify(body) : undefined, + signal, + }); } diff --git a/frontend/src/api/mutations/baseline.ts b/frontend/src/api/mutations/baseline.ts new file mode 100644 index 00000000..66b22222 --- /dev/null +++ b/frontend/src/api/mutations/baseline.ts @@ -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('/overview/baseline', { scan_id: scanId }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['overview'] }); + }, + }); +} + +export function useUnpinBaseline() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => apiDelete('/overview/baseline'), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['overview'] }); + }, + }); +} diff --git a/frontend/src/api/mutations/config.ts b/frontend/src/api/mutations/config.ts index 368d8641..ef96ff0f 100644 --- a/frontend/src/api/mutations/config.ts +++ b/frontend/src/api/mutations/config.ts @@ -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('/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('/config/sources'), + mutationFn: (body: AddLabelBody) => + apiDelete('/config/sources', body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['config', 'sources'] }); + qc.invalidateQueries({ queryKey: ['rules'] }); }, }); } @@ -41,6 +44,7 @@ export function useAddSink() { apiPost('/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('/config/sinks'), + mutationFn: (body: AddLabelBody) => apiDelete('/config/sinks', body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['config', 'sinks'] }); + qc.invalidateQueries({ queryKey: ['rules'] }); }, }); } @@ -64,6 +69,7 @@ export function useAddSanitizer() { apiPost('/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('/config/sanitizers'), + mutationFn: (body: AddLabelBody) => + apiDelete('/config/sanitizers', body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['config', 'sanitizers'] }); + qc.invalidateQueries({ queryKey: ['rules'] }); }, }); } @@ -92,6 +100,7 @@ export function useAddTerminator() { apiPost('/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('/config/terminators'), + apiDelete('/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() { diff --git a/frontend/src/api/queries/config.ts b/frontend/src/api/queries/config.ts index e26c3b4f..cabec4ae 100644 --- a/frontend/src/api/queries/config.ts +++ b/frontend/src/api/queries/config.ts @@ -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('/config/raw', signal), + }); +} + export function useSources() { return useQuery({ queryKey: ['config', 'sources'], diff --git a/frontend/src/api/queries/debug.ts b/frontend/src/api/queries/debug.ts index ee2d713b..20f413c3 100644 --- a/frontend/src/api/queries/debug.ts +++ b/frontend/src/api/queries/debug.ts @@ -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(`/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( + `/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( + `/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( + `/debug/auth?file=${encodeURIComponent(file!)}`, + signal, + ), + enabled: !!file, + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 149f5b06..3bf83a9d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; +} + +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; +} diff --git a/frontend/src/components/icons/Icons.tsx b/frontend/src/components/icons/Icons.tsx index 078fa316..54c25b4a 100644 --- a/frontend/src/components/icons/Icons.tsx +++ b/frontend/src/components/icons/Icons.tsx @@ -108,15 +108,6 @@ export function DebugIcon({ className, size = 18 }: IconProps) { ); } -export function SettingsIcon({ className, size = 18 }: IconProps) { - return ( - - - - - ); -} - export function FolderIcon({ className, size = 14 }: IconProps) { return ( + + + ); +} + +export function SunIcon({ className, size = 16 }: IconProps) { + return ( + + + + + ); +} + +export function MoonIcon({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function RefreshIcon({ className, size = 16 }: IconProps) { + return ( + + + + + ); +} + +export function CommandIcon({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + /** Map of icon name to component, for dynamic lookup */ export const ICONS: Record> = { overview: OverviewIcon, @@ -164,7 +197,6 @@ export const ICONS: Record> = { config: ConfigIcon, explorer: ExplorerIcon, debug: DebugIcon, - settings: SettingsIcon, folder: FolderIcon, tag: TagIcon, }; diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 02c78124..7616ca9f 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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( + () => [ + // 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 (
    - + setPaletteOpen(true)} + />
    } /> @@ -53,8 +148,11 @@ export function AppLayout() { /> } /> } /> + } + /> - } />
    @@ -62,6 +160,12 @@ export function AppLayout() { open={scanModalOpen} onClose={() => setScanModalOpen(false)} /> + setPaletteOpen(false)} + commands={commands} + /> + setHelpOpen(false)} />
    ); } diff --git a/frontend/src/components/layout/HeaderBar.tsx b/frontend/src/components/layout/HeaderBar.tsx index ba6c2fc3..cf6f5390 100644 --- a/frontend/src/components/layout/HeaderBar.tsx +++ b/frontend/src/components/layout/HeaderBar.tsx @@ -1,4 +1,5 @@ import { Link, useLocation } from 'react-router-dom'; +import { CommandIcon } from '../icons/Icons'; const SECTION_TITLES: Record = { overview: 'Overview', @@ -9,7 +10,6 @@ const SECTION_TITLES: Record = { config: 'Config', explorer: 'Explorer', debug: 'Debug', - settings: 'Settings', }; const ROUTE_TITLES: Record = { @@ -17,6 +17,7 @@ const ROUTE_TITLES: Record = { '/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 (
    -
    + {onOpenPalette && ( + + )} {onStartScan && ( - )} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 41b57199..31b3d060 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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', }, ]; diff --git a/frontend/src/components/overview/OverviewWidgets.tsx b/frontend/src/components/overview/OverviewWidgets.tsx new file mode 100644 index 00000000..ed686c0d --- /dev/null +++ b/frontend/src/components/overview/OverviewWidgets.tsx @@ -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 ( +
    +
    Health Score
    +
    +
    + {health.grade} +
    +
    +
    + {health.score} + / 100 +
    + {posture && ( +
    + {posture.message} +
    + )} +
    +
    + {health.components.map((c) => ( +
    +
    {c.score}
    +
    {c.label}
    +
    + ))} +
    +
    +
    + ); +} + +// ── PostureBanner ────────────────────────────────────────────────────────── + +export function PostureBanner({ posture }: { posture: PostureSummary }) { + return ( +
    + + {posture.message} +
    + ); +} + +// ── 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 ( +
    +
    Backlog Age
    +
    +
    +
    + {backlog.oldest_open_days != null + ? `${backlog.oldest_open_days}d` + : '–'} +
    +
    Oldest open
    +
    +
    +
    + {backlog.median_age_days != null + ? `${backlog.median_age_days}d` + : '–'} +
    +
    Median age
    +
    +
    +
    {backlog.stale_count}
    +
    Older than 30 days
    +
    + {total > 0 && ( +
    + +
    + )} +
    +
    + ); +} + +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 ( +
    `${b.name}: ${b.count}`).join(' · ')} + > + {buckets.map((b, i) => ( +
    + ))} +
    + ); +} + +// ── ConfidenceDistributionChart ──────────────────────────────────────────── + +export function ConfidenceDistributionChart({ + dist, +}: { + dist: ConfidenceDistribution; +}) { + const total = dist.high + dist.medium + dist.low + dist.none; + if (total === 0) { + return ( +
    +

    No data

    +
    + ); + } + 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 ( +
    +
    + {segments.map((s) => + s.value > 0 ? ( +
    + ) : null, + )} +
    +
    + {segments.map((s) => ( +
    + + {s.label} + {s.value} +
    + ))} +
    +
    + ); +} + +// ── 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 ( +
    + {rows.map((r) => ( +
    +
    + {r.label} +
    +
    +
    {r.value}
    + {r.detail &&
    {r.detail}
    } +
    +
    + ))} +
    + ); +} + +// ── HotSinksList ─────────────────────────────────────────────────────────── + +export function HotSinksList({ sinks }: { sinks: HotSink[] }) { + if (!sinks.length) { + return ( +
    +

    No data

    +
    + ); + } + return ( + + + + + + + + + {sinks.map((s) => ( + + + + + ))} + +
    SinkFindings
    {s.callee}{s.count}
    + ); +} + +// ── OwaspChart ───────────────────────────────────────────────────────────── + +export function OwaspChart({ buckets }: { buckets: OwaspBucket[] }) { + if (!buckets.length) { + return ( +
    +

    No data

    +
    + ); + } + const max = Math.max(...buckets.map((b) => b.count), 1); + return ( +
      + {buckets.map((b) => ( +
    • + {b.code} + {b.label} +
      +
      +
      + {b.count} +
    • + ))} +
    + ); +} + +// ── WeightedTopFiles ─────────────────────────────────────────────────────── + +export function WeightedTopFiles({ + files, + onRowClick, +}: { + files: WeightedFile[]; + onRowClick?: (name: string) => void; +}) { + if (!files.length) { + return ( +
    +

    No data

    +
    + ); + } + return ( + + + + + + + + + + {files.map((f) => ( + onRowClick(f.name) : undefined} + > + + + + + ))} + +
    FileSeverityScore
    {truncPath(f.name, 45)} + + {f.score}
    + ); +} + +function SeverityStack({ + high, + medium, + low, +}: { + high: number; + medium: number; + low: number; +}) { + const total = high + medium + low; + if (total === 0) return null; + return ( +
    + {high > 0 && ( +
    + {high} +
    + )} + {medium > 0 && ( +
    + {medium} +
    + )} + {low > 0 && ( +
    + {low} +
    + )} +
    + ); +} + +// ── LanguageHealthTable ──────────────────────────────────────────────────── + +export function LanguageHealthTable({ rows }: { rows: LanguageHealth[] }) { + if (!rows.length) { + return ( +
    +

    No data

    +
    + ); + } + return ( + + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
    LanguageFindingsSeverity
    {r.language}{r.findings} + +
    + ); +} + +// ── 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 ( +
    +
    +
    + Blanket rate + Lower is better +
    +
    +
    {blanketDisplay}
    +
    {blanketDetail}
    +
    +
    +
    +
    + By fingerprint + Most specific +
    +
    +
    {hygiene.fingerprint_level}
    +
    +
    +
    +
    + By rule in a file +
    +
    +
    {hygiene.rule_in_file_level}
    +
    +
    +
    +
    + By rule +
    +
    +
    {hygiene.rule_level}
    +
    +
    +
    +
    + By file + Least specific +
    +
    +
    {hygiene.file_level}
    +
    +
    +
    + ); +} + +// ── 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 ( +
    + Baseline: + + + drift: +{baseline.drift_new} new / -{baseline.drift_fixed} fixed ( + {net >= 0 ? '+' : ''} + {net}) + + +
    + ); + } + if (!latestScanId) return null; + return ( +
    + No baseline pinned. + +
    + ); +} diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx new file mode 100644 index 00000000..75a30bd8 --- /dev/null +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -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(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) => { + 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(); + 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 ( +
    +
    +
    + setQuery(e.target.value)} + onKeyDown={onKeyDown} + aria-label="Command search" + aria-autocomplete="list" + /> +
      + {filtered.length === 0 && ( +
    • No matches
    • + )} + {Array.from(groups.entries()).map(([group, items]) => ( +
    • + {group &&
      {group}
      } +
        + {items.map((cmd) => { + const idx = runningIndex++; + const active = idx === highlight; + return ( +
      • setHighlight(idx)} + onClick={() => run(cmd)} + > + {cmd.icon && ( + {cmd.icon} + )} + {cmd.label} + {cmd.hint && ( + {cmd.hint} + )} + {cmd.shortcut && ( + {cmd.shortcut} + )} +
      • + ); + })} +
      +
    • + ))} +
    +
    +
    + ); +} diff --git a/frontend/src/components/ui/ErrorState.tsx b/frontend/src/components/ui/ErrorState.tsx index c7ab5a6f..3aed2038 100644 --- a/frontend/src/components/ui/ErrorState.tsx +++ b/frontend/src/components/ui/ErrorState.tsx @@ -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 ( -
    -

    {title}

    -

    {message}

    +
    +

    {resolved.title}

    +

    {resolved.message}

    ); } diff --git a/frontend/src/components/ui/LoadingState.tsx b/frontend/src/components/ui/LoadingState.tsx index a6f25cfc..8ee4658a 100644 --- a/frontend/src/components/ui/LoadingState.tsx +++ b/frontend/src/components/ui/LoadingState.tsx @@ -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
    {message}
    ; + return ( +
    +
    + ); } diff --git a/frontend/src/components/ui/ShortcutsHelp.tsx b/frontend/src/components/ui/ShortcutsHelp.tsx new file mode 100644 index 00000000..ed9f2062 --- /dev/null +++ b/frontend/src/components/ui/ShortcutsHelp.tsx @@ -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 ( +
    +
    +
    +
    +

    Keyboard shortcuts

    + +
    +
    + {ROWS.map((section) => ( +
    +

    {section.section}

    +
    + {section.rows.map((row) => ( +
    +
    + {row.keys.map((k, i) => ( + + {i > 0 && then} + {k} + + ))} +
    +
    {row.description}
    +
    + ))} +
    +
    + ))} +
    +
    +
    + ); +} diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx new file mode 100644 index 00000000..61cc0e96 --- /dev/null +++ b/frontend/src/components/ui/Toaster.tsx @@ -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 ( +
    + {toasts.map((t) => ( +
    +
    + {t.title &&
    {t.title}
    } +
    {t.message}
    +
    + +
    + ))} +
    + ); +} diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 00000000..1dd8cbfc --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -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(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( + 'theme', + 'system', + ); + + const resolved = useMemo(() => resolve(preference), [preference]); + + // Reflect the resolved theme onto 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( + () => ({ preference, resolved, setPreference, cycle }), + [preference, resolved, setPreference, cycle], + ); + + return ( + {children} + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used inside '); + return ctx; +} diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 00000000..a3c5cda6 --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -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 & { 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(null); + +const DEFAULT_DURATION: Record = { + 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([]); + const nextId = useRef(1); + const timers = useRef(new Map()); + + 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( + ({ 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( + () => ({ + 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 ( + {children} + ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used inside '); + return ctx; +} diff --git a/frontend/src/hooks/useChordNavigation.ts b/frontend/src/hooks/useChordNavigation.ts new file mode 100644 index 00000000..b15b9270 --- /dev/null +++ b/frontend/src/hooks/useChordNavigation.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; + +const CHORD_TIMEOUT_MS = 800; + +const ROUTES: Record = { + 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(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]); +} diff --git a/frontend/src/hooks/useFindingsURLState.ts b/frontend/src/hooks/useFindingsURLState.ts index bf6330e0..da7869f1 100644 --- a/frontend/src/hooks/useFindingsURLState.ts +++ b/frontend/src/hooks/useFindingsURLState.ts @@ -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 = new Set([ 'severity', 'category', @@ -49,16 +65,42 @@ const NON_RESET_KEYS: ReadonlySet = new Set([ export function useFindingsURLState() { const [searchParams, setSearchParams] = useSearchParams(); + const [prefs, setPrefs] = usePersistedState( + '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) => { diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..d0d8d8f8 --- /dev/null +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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]); +} diff --git a/frontend/src/hooks/usePageTitle.ts b/frontend/src/hooks/usePageTitle.ts new file mode 100644 index 00000000..4df4d8d4 --- /dev/null +++ b/frontend/src/hooks/usePageTitle.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; + +const APP_NAME = 'Nyx'; + +/** + * Sets `document.title` to ` · 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]); +} diff --git a/frontend/src/hooks/usePersistedState.ts b/frontend/src/hooks/usePersistedState.ts new file mode 100644 index 00000000..688e64ec --- /dev/null +++ b/frontend/src/hooks/usePersistedState.ts @@ -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(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(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:`. + * + * 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( + key: string, + initial: T, +): [T, (next: T | ((prev: T) => T)) => void] { + const [state, setState] = useState(() => 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]; +} diff --git a/frontend/src/modals/NewScanModal.tsx b/frontend/src/modals/NewScanModal.tsx index 86922d62..f9b17c6e 100644 --- a/frontend/src/modals/NewScanModal.tsx +++ b/frontend/src/modals/NewScanModal.tsx @@ -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('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'); } }; diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index 5c4b8d92..1cbd2fb0 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -1,6 +1,7 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { useConfig, + useRawConfig, useSources, useSinks, useSanitizers, @@ -20,11 +21,23 @@ import { useDeleteProfile, useActivateProfile, useToggleTriageSync, + useSaveRawConfig, } from '../api/mutations/config'; import { LoadingState } from '../components/ui/LoadingState'; import { ErrorState } from '../components/ui/ErrorState'; +import { usePageTitle } from '../hooks/usePageTitle'; +import { useToast } from '../contexts/ToastContext'; +import { useTheme, type ThemePreference } from '../contexts/ThemeContext'; import type { LabelEntryView, TerminatorView, ProfileView } from '../api/types'; +const THEME_OPTIONS: Array<{ value: ThemePreference; label: string }> = [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'System' }, + { value: 'hc-light', label: 'High-contrast light' }, + { value: 'hc-dark', label: 'High-contrast dark' }, +]; + const LANG_OPTIONS = [ 'javascript', 'typescript', @@ -53,18 +66,22 @@ const CAP_OPTIONS = [ 'crypto', ]; +type Tab = 'overview' | 'rules' | 'profiles' | 'raw'; + // ── Collapsible Config Section ─────────────────────────────────────────────── function ConfigSection({ title, id, + defaultCollapsed = false, children, }: { title: string; - id: string; + id?: string; + defaultCollapsed?: boolean; children: React.ReactNode; }) { - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(defaultCollapsed); return (
    @@ -86,9 +103,100 @@ function ConfigSection({ ); } -// ── Label Table (Source/Sink/Sanitizer) ────────────────────────────────────── +// ── Top-of-page settings panel (theme + triage sync) ──────────────────────── -function LabelSection({ +function SettingsSection({ + triageSyncOn, + onToggleTriageSync, +}: { + triageSyncOn: boolean; + onToggleTriageSync: (enabled: boolean) => void; +}) { + const { preference, setPreference } = useTheme(); + + return ( +
    +
    + Settings +
    +
    +
    + + +
    +
    + onToggleTriageSync(e.target.checked)} + /> + +
    +
    +
    + ); +} + +// ── Read-only key/value grid for effective config display ─────────────────── + +function KvGrid({ entries }: { entries: Array<[string, React.ReactNode]> }) { + return ( +
    + {entries.map(([k, v]) => ( +
    +
    {k}
    +
    {v}
    +
    + ))} +
    + ); +} + +function fmt(v: unknown): React.ReactNode { + if (v === null || v === undefined) return ; + if (typeof v === 'boolean') + return ( + + {v ? 'on' : 'off'} + + ); + if (Array.isArray(v)) { + if (v.length === 0) return []; + return ( + + {v.map(String).map((s, i) => ( + + {s} + + ))} + + ); + } + if (typeof v === 'object') + return {JSON.stringify(v)}; + return {String(v)}; +} + +// ── Custom rules table (no built-ins; built-ins live on /rules) ───────────── + +function CustomLabelSection({ title, id, kind, @@ -98,7 +206,7 @@ function LabelSection({ }: { title: string; id: string; - kind: string; + kind: 'source' | 'sink' | 'sanitizer'; entries: LabelEntryView[]; onAdd: (body: { lang: string; matchers: string[]; cap: string }) => void; onDelete: (entry: LabelEntryView) => void; @@ -107,9 +215,6 @@ function LabelSection({ const [matcher, setMatcher] = useState(''); const [cap, setCap] = useState('all'); - const builtins = entries.filter((e) => e.is_builtin); - const custom = entries.filter((e) => !e.is_builtin); - const handleAdd = useCallback(() => { if (!lang || !matcher) return; onAdd({ lang, matchers: [matcher], cap }); @@ -118,15 +223,15 @@ function LabelSection({ return ( -
    +

    + Custom {kind} rules from your nyx.local. Built-in rules are + listed on the Rules page. +

    +
    - setLang(e.target.value)}> + {LANG_OPTIONS.map((l) => (
    -
    +
    setMatcher(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleAdd(); + }} />
    @@ -153,14 +261,18 @@ function LabelSection({ ))}
    -
    -
    +
    {entries.length === 0 ? (
    -

    No {kind} rules

    +

    No custom {kind} rules yet

    ) : ( @@ -173,24 +285,10 @@ function LabelSection({ - {builtins.map((e, i) => ( - - - - - - - ))} - {custom.map((e, i) => ( + {entries.map((e, i) => ( - +
    {e.lang} - {e.matchers.join(', ')} - {e.cap} - built-in -
    {e.lang} - {e.matchers.join(', ')} - {e.matchers.join(', ')} {e.cap} + + + + {saveError && ( +
    + Save failed: {saveError} +
    + )} +