mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
Release/0.5.0 (#35)
* feat: Introduce function-scoped variable interning for state analysis with new tests and fixtures * feat: Add Phase 26 symbolic execution enhancements with bitwise operator support, abstract interpretation refinements, and new taint analysis tests * feat: Refine state analysis to handle factory-pattern resource returns with mixed-path tests and leak detection enhancements * feat: Add Phase 27 debug views with symbolic execution, abstract interpretation, SSA, and call graph viewers; integrate with debug layout and styles * feat: Add Phase 31 type-qualified symbolic resolution with receiver-based callee disambiguation and testing * feat: Extend symbolic execution with state iteration, enhanced debug views, and debounced input handling * feat: Add Phase 13 resource and auth pattern extensions with new tests and fixtures * feat: Introduce CFG debug graph renderer with compact mode, toolbar, and DAG layout integration * feat: Add Phase 28 encoding and decoding transform modeling with structural symex enhancements and new taint analysis tests * feat: Extend abstract interpretation with type facts and constant value tracking in debug views and server logic * feat: Add linear path handling and witness extraction to symbolic execution with Phase 28 transform mismatch detection * feat: Refine Go auth and sanitizer handling with enhanced rules, state updates, and benchmark improvements * feat: Enable auth-state analysis by default and update relevant tests in benchmark config * test: Update state_tests to reflect default enablement of auth-state analysis and add auth suppression test * docs: update CHANGELOG.md * feat: Introduce per-index taint tracking in `HeapState` with `HeapSlot`, overflow handling, and revised SSA transfers * feat: Introduce C/C++ language labels and refine heap state tracking in SSA transfers * feat: Implement per-index array slot tracking in symbolic heap with overflow collapse * feat: Add implicit definition handling for uninitialized declarations in SSA value allocation * feat: Refactor function parameters and constants for improved clarity and maintainability * refactor: Reorder module imports and improve formatting for consistency * refactor: Fix formatting erorrs * refactor: Fix clippy warnings * refactor: Fix fmt warnings (again) * chore: Update dependencies and improve feature configuration * Add comprehensive tests for undertested modules (#36) (COPILOT) * Add comprehensive tests for undertested modules Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/f3fc877e-f386-49ba-9793-fc93d3805083 * Add comprehensive tests for ext, project, walk, and errors modules Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/f3fc877e-f386-49ba-9793-fc93d3805083 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * chore: Update dependencies and improve feature configuration * fix: formatting errors in new tests * chore: Update license list in about.toml * chore: made functions input inline * chore: updated cfg graph to take up the full page * chore: add Prettier configuration and update code formatting * Add frontend test suite with Vitest (111 tests) (#37) * Add Vitest test suite for frontend - 111 tests across utils, components, hooks, and graph utilities Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/7cf0dba2-ecff-4740-ba4d-92717e74a0b7 * ci: add frontend test step to CI workflow Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/5bc0ac9f-0a32-4d03-9cb7-7a15aea53fca --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * chore: simplify array initialization in test files for consistency * ran typecheck * feat: add AnalysisWorkspace component and integrate it into CfgViewerPage * feat: update routing in AppLayout and improve empty state message in ExplorerPage * feat: enhance scan progress tracking with additional metrics and stages * feat: update license information and add license check script * feat: implement cross-file symbolic execution with callee body persistence * feat: replace dagre graphs with Graphology + ELK + Sigma for more advanced call stack and cfg rendering * feat: ensure CFG function view is scoped to the selected function, preventing bleed into sibling functions * feat: enhance resource tracking with proxy method summaries and improve finding extraction * feat: add terminal function exit detection for accurate resource leak analysis * feat: add warnings for loops and functions without bodies to improve error recovery * feat: update lambda expression handling to ensure proper function classification and control flow * feat: remove bounded formatting/string ops and add JSON.parse sanitizer for improved data handling * feat: add inline return taint analysis and regression tests for improved security checks * feat: add engine version management and migration handling for database schema updates * feat: enhance first_call_ident to skip nested function bodies and add regression tests * feat: enhance callee name resolution with two-segment normalization and disambiguation * feat: add cross-file context flags and debug assertions for taint analysis * feat: refactor taint analysis structure to unify context handling and improve clarity * feat: enhance dead code elimination to preserve Sink, Source, and Sanitizer labels with new tests * docs: updated CHANGELOG.md * fmt: formatting fixes * fix: fixed frontend formatting and lint warnings * fix: optimized ci * fix: optimized ci * Add comprehensive multi-file test coverage to Nyx (#38) * Initial checklist for multi-file test suite expansion Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/e550cb88-9767-4442-94d4-101bf5bb0e23 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * Add 12 new multi-file test fixtures with TP/TN/near-miss coverage Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/e550cb88-9767-4442-94d4-101bf5bb0e23 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * deleted root repo * rebuilt to test for regressions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Co-authored-by: elipeter <elicpeter@gmail.com> * feat: enhance import alias resolution and taint tracking * feat: implement security hardening with CSRF protection and path validation * feat: add support for import alias bindings in Python, PHP, and Rust * feat: enhance CFG analysis modes and improve code readability * feat: add detection for parameterized SQL queries to enhance security * feat: add safe internal redirect handling and enhance session destroy validation * feat: implement security improvements by addressing vulnerabilities in execAsync, session management, and file downloads * feat: enhance taint detection by adding support for inline source member expressions in call arguments * feat: implement pre-emission of Source nodes for inline source member expressions in call arguments * feat: add support for Throw statement in control flow and error handling * feat: add debug and echo endpoints with potential information leakage * feat: implement internal redirect suppression and enhance taint detection * feat: implement module alias tracking for dynamic dispatch in JS/TS * feat: add authorization analysis module with Express support * feat: add authorization analysis module with Express support * feat: add tests for admin guard requirements and clean checks in authorization analysis * feat: integrate Koa and Fastify frameworks into authorization analysis * feat: add Flask and Django support to authorization analysis module * feat: add support for Rails and Sinatra frameworks in authorization analysis * feat: add support for Axum, ActixWeb, and Rocket frameworks in authorization analysis * feat: add support for ActixWeb, Axum, and Rocket frameworks in authorization analysis * feat: add support for Rails and Sinatra in authorization analysis * chore: add .DS_Store to .gitignore * refactor: simplify conditional checks and improve readability in multiple files * refactor: update usage of Option methods for improved clarity and consistency * refactor: improve code readability by simplifying conditional checks and formatting * refactor: improve code formatting and readability by simplifying conditional checks * refactor: simplify conditional checks and improve readability in multiple files * refactor: simplify conditional checks in axum.rs for improved readability * feat: add CodeQL analysis configuration for enhanced security scanning * test: add comprehensive tests for `src/output.rs` SARIF builder (#39) * chore: start test coverage improvement work Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/cd7ff398-134e-4728-a5e7-0353a0744423 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * test: add comprehensive tests for src/output.rs SARIF builder Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/cd7ff398-134e-4728-a5e7-0353a0744423 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * refactor: improve code formatting and readability in output.rs --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Co-authored-by: elipeter <elicpeter@gmail.com> * refactor: improve code formatting and readability in output.rs * Potential fix for code scanning alert no. 210: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for code scanning alert no. 211: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * refactor: enhance triage file path handling with improved error management and validation * refactor: updated func summaries for richer detail * refactor: update SSA summary extraction to use canonical FuncKey for distinct entries * refactor: enhance callee metadata structure to support arity, receiver, and qualifier for better overload resolution * refactor: add support for keyword arguments in function calls and enhance receiver extraction for method-style calls * refactor: implement new Flask routes for safe and unsafe shell command execution * refactor: separate receiver handling in SSA operations and enhance taint propagation * refactor: improve arity handling by using arg_uses for positional argument count and enhance witness scoring for tainted arguments * refactor: implement auth decorator extraction and classification for multiple languages * refactor: enhance Rust module path resolution and use map handling for cross-file disambiguation * refactor: introduce CalleeQuery struct for structured callee resolution and enhance resolver logic * refactor: implement same-file identity collision handling for `runTask` to ensure correct resolver behavior * refactor: standardize default struct initialization across multiple files * feat: add scripts for formatting checks and auto-fixes with test summaries * refactor: simplify character splitting and enhance namespace qualifier handling * refactor: improve documentation clarity and enhance code readability in resolver logic * refactor: replace default struct initialization with explicit field assignments for clarity * feat: enhance anonymous function naming by deriving context-based bindings * refactor: streamline match expressions for improved readability and performance * refactor: streamline match expressions for improved readability and performance * refactor: replace loop with while let for improved clarity and performance * feat: add SSA constant propagation support to analysis context for improved accuracy * feat: add SSA constant propagation support to analysis context for improved accuracy * feat: implement shell metacharacter validation and bounded-length checks in Rust analysis * feat: add static map analysis for command injection suppression and type safety * refactor: simplify match statements and reduce line breaks for improved readability * feat(summary): phase 1/5 SinkSite data model for primary sink-location attribution Introduce SinkSite (file_rel, line, col, snippet, cap) carrying the primary sink source-location through function summaries. Swap SsaFuncSummary.param_to_sink and FuncSummary.param_to_sink from a coarse Cap map to a deduped SmallVec<[SinkSite; 1]> per parameter, with a backward-compatible cap_sites() helper and serde defaults so pre-phase-1 on-disk rows continue to deserialise cleanly. Extraction: SinkSiteLocator bundles the tree/bytes/file_rel needed by extract_ssa_func_summary; ParsedFile::extract_ssa_artifacts wires the locator in for the persisted pass-1 path, while pass-2 intra-file transient summaries fall back to cap-only sites (behavior unchanged). Merge: GlobalSummaries::insert now unions sink sites with (file_rel, line, col, cap) dedup via shared union_param_sink_sites helper. Database: JSON-serialised summary columns carry the new shape automatically; no schema change needed. Phase 2 will consume SinkSite in build_taint_diag() to overwrite the caller-site Finding.line with the callee's sink line when resolved via summary. Phase 1 keeps behavior unchanged: scanning tests/benchmark/corpus/rust/cmdi/cmdi_indirect.rs still produces the same (wrong) line 10 finding. Adds round-trip tests covering SinkSite solo, SsaFuncSummary with sink sites, legacy-JSON default handling for both summary types, and merge dedup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(taint): phase 2/5 thread SinkSite into SsaTaintEvent and Finding Plumb Phase 1's SinkSite through the event pipeline into Findings, no output change yet. SsaTaintEvent gains `primary_sink_site: Option<SinkSite>`; when the main or callback sink-emission path has non-empty `param_to_sink_sites`, filter to sites whose `(line != 0) && (cap ∩ sink_caps != ∅)` and emit one event per distinct site — the multi-primary collapse keeps each downstream Finding single-primary. Resolution: ResolvedSummary and SinkInfo gain mirror `param_to_sink_sites` fields, populated from `SsaFuncSummary.param_to_sink` (SSA + callback paths) and `FuncSummary.param_to_sink` (global paths). Label, local-summary, and interop resolution paths leave the field empty — they only ever had cap-level info to begin with. Finding: new `primary_location: Option<SinkLocation>` with `file_rel/line/col`. `ssa_events_to_findings` maps `event.primary_sink_site` → `Finding.primary_location`, filtering cap-only sites (`line == 0`) to `None` so the (0,0) sentinel never leaks to formatters. Dedup key extended with the primary location so multi-site events aren't collapsed back together. Invariants (debug_assert!): * every SinkSite reaching emission has `line != 0 && cap ∩ sink_caps != ∅` — enforced by the pick_primary_sink_sites* filters; * every populated Finding.primary_location has `line != 0` AND non-empty `file_rel` — the cap-only → None translation upstream guarantees this. Deliberately independent of `uses_summary`: that flag tracks whether the *taint chain* used a summary, whereas primary attribution requires only that the *sink* itself was summary-resolved. A local source reaching a cross-file sink produces `uses_summary=false` alongside a populated primary_location — documented on Finding.primary_location, covered by `cross_file_sink_finding_carries_primary_location`. build_taint_diag, SARIF/JSON/explanation formatters, and the benchmark scorer remain untouched: finding.line still comes from `cfg_graph[finding.sink]`, so cmdi_indirect.rs still reports line 10 and the benchmark's rs-cmdi-003 row still shows FN in the LOC column. Tests: `cross_file_sink_finding_carries_primary_location` (proves plumbing via a synthetic FuncSummary carrying a SinkSite at 42:5) and `cross_file_sink_cap_only_site_leaves_primary_location_none` (regression guard against cap-only sites surfacing). All 1566 lib tests + integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(output): phase 3/5 consume primary sink location in diag + SARIF When a finding's primary_location (populated in phase 2 from a callee summary's SinkSite) names the dangerous instruction inside a callee body, attribute the diagnostic line to that location instead of the caller's call site. The call site is demoted to a Call step in flow_steps, and a synthetic Sink step at the primary location is appended so analysts still see the full trace. Changes: - Add scan_root parameter to build_taint_diag so file_rel can be resolved back to an absolute path via a shared resolve_file_rel helper. Empty file_rel (single-file scans where namespace == "") resolves to the file under analysis. - Extend SinkLocation with snippet, carried from the upstream SinkSite so the formatter needs no second file read. - Relax the ssa_events_to_findings debug_assert to allow empty file_rel, which is valid when scan root equals the file itself. - SARIF: emit data-flow as codeFlows[0].threadFlows[0].locations[]; locations[0] already reflects the primary sink position via the updated diag line/col. Acceptance: scan on tests/benchmark/corpus/rust/cmdi/cmdi_indirect.rs now reports line 5 (Command::new) as the primary sink, with the call site at line 10 visible in flow_steps. Two expect.json fixtures updated (must_match line_range widened): - javascript/taint/context_sensitive_call: 12-14 -> 7-14 (line 8 is the real sink inside run()). - rust/cfg/closure_async: 10-10 -> 10-11 (line 11 is Command::new inside the closure). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(bench): phase 4/5 validate primary sink attribution across corpus Extend the benchmark scorer and ground truth to lock in phase 3's primary-location behavior, and add fixtures that exercise the new capability end-to-end. Scorer (tests/benchmark_test.rs): - Add optional `expected_call_site_lines: Option<Vec<[usize; 2]>>` on Case. When present, score_location_level additionally requires at least one flow_step in the finding's evidence trace to fall within ±2 of the call-site range. When absent, the check is skipped — fully forward-compatible with existing fixtures. - Retain ±2 tolerance on expected_sink_lines (compared against the now-primary Diag.line post-phase-3). Ground truth edits: - rs-cmdi-cross-001: expected_sink_lines [8,8] -> [9,9]. Line 8 is the transform::wrap call site (a cross-file propagator, not a sink); line 9 is Command::new, the real sink. The ±2 tolerance happened to mask this stale attribution but it was semantically wrong — phase 4 is the right time to correct it. Also adds expected_call_site_lines [8,8] so the new field is exercised on an existing cross-file case. - rs-cmdi-003: adds expected_call_site_lines [10,10] (run_cmd call). This fixture's sink (Command::new inside run_cmd at line 5) was the motivating case for phases 1-3; adding the call-site assertion guards against regression to caller-line attribution. New fixtures: - rust/cmdi/cmdi_indirect_multisink.rs (rs-cmdi-009): helper run_both takes two tainted params and invokes two Command sinks on consecutive lines. Locks in that primary line lands inside the helper (lines 5-6), not at the caller (line 12). Notes document that SinkSite is currently one-per-callee so both findings today collapse onto the first sink; expected_sink_lines=[5,6] and expected_call_site_lines=[12,12] stay valid either way. - python/cmdi/cross_indirect_sink/{app.py,helper.py} (py-cmdi-cross- 004): sink os.system lives in helper.py (cross-file), caller in app.py reads env source and calls run_cmd. Verifies phase 3's cross-file primary attribution: Diag.path = helper.py, Diag.line = 5, with app.py:7 recorded in flow_steps as a Call step. Acceptance: - `cargo test --test benchmark_test -- --ignored --nocapture` passes. - rs-cmdi-003 is TP/TP/TP (the target flip FN->TP at LOC). All pre-existing TP/TP/TP fixtures remain TP/TP/TP; 2 new fixtures are TP/TP/TP. - Aggregate rule-level: TP=158 FP=10 FN=1 TN=97, P=0.940 R=0.994 F1=0.966 on the 266-case corpus (was TP=156 FP=10 FN=1 TN=97 on 264 pre-phase-4, delta is the +2 new cases both resolving TP). - Full `cargo test` green (1566 lib tests + all integration tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(taint): phase 5/5 lock Finding.primary_location contract via regression test Add a regression test in src/taint/ssa_transfer.rs that wires up a synthetic SsaFuncSummary with a SinkSite at other.rs:42:10 and drives the three emission stages (pick_primary_sink_sites → emit_ssa_taint_events → ssa_events_to_findings) against a minimal caller SSA body. Asserts the resulting Finding.primary_location is exactly that triple. The existing integration tests in src/taint/tests.rs cover the coarse FuncSummary path end-to-end through analyse_file. This test locks in the lower-level SSA-side plumbing so a future refactor that silently drops the site between pick → emit → findings fails here rather than only at the benchmark layer. Also refreshes tests/benchmark/results/latest.json (timestamp only; rs-cmdi-003 remains TP/TP/TP and the aggregate P/R/F1 are unchanged from phase 4). Closes the primary sink-location attribution feature (phases 1-5/5): * Phase 1 — SinkSite data model on summaries. * Phase 2 — SinkSite threaded into SsaTaintEvent and Finding. * Phase 3 — diag + SARIF consume primary_location. * Phase 4 — benchmark validates primary_call_site_lines across corpus. * Phase 5 — regression test locks the event→finding contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: clean up formatting and improve readability in multiple files * refactor: simplify type definition for deduplication key in findings * test(harness): add must_not_match expectation for FP regression guards Extends ExpectedFinding with must_not_match field that asserts a diagnostic must NOT fire — presence is a hard failure. Non-consuming scan so it coexists with must_match entries on the same rule_id. Adds forbidden_violations accumulator and updates summary line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(regression): update expectations to ensure must_not_match for various taint and resource leak rules * feat: implement auto-seeding for JS/TS handler parameters to enhance taint tracking * feat: update switch statement handling to improve control flow analysis * feat: implement promisify alias handling for JS/TS to enhance taint tracking * feat: enhance taint tracking by refining expectation handling and adding mode filtering * feat: refine SQL handling in stream processing and enhance auto-seeding for handler parameters * feat: update taint tracking rules to enforce full mode matching and improve flow analysis * feat: enhance Ruby subshell handling to improve taint tracking and flow analysis * feat: update xss_response expectations to refine taint flow analysis and enhance regression guarding * feat: refine framework detection and update expectation handling for Echo and Sinatra * feat: implement max_count for taint tracking expectations and deduplicate findings * feat: add strict_unexpected handling for taint-unsanitised-flow in expectation files * feat: enhance deduplication of taint-unsanitised-flow findings by collapsing based on line and severity * feat: add strict_unexpected handling for taint-unsanitised-flow in multiple expectation files * feat: add structural invariant checks for SSA bodies * feat: ensure deterministic phi emission order using BTreeSet * feat: enhance handling of terminators to ensure authoritative flow through successor edges * feat: enhance Goto terminator handling to ensure all successors are marked executable * feat: refactor code for improved readability and organization * feat: simplify predicate checks and enhance readability in SSA handling * feat: implement per-file parse timeout and enhance file size handling * feat: migrate analysis engine toggles from environment variables to configuration file * feat: remove unnecessary whitespace in hostile_input_tests.rs * feat: remove unnecessary whitespace in hostile_input_tests.rs * feat: update dependencies and enhance documentation on language maturity * feat: enhance security headers and improve request body limits * feat: implement sink capability bits for deduplication and enhance evidence tagging * feat: implement dynamic activation handling for gated sinks and enhance validation logic * feat: enhance configuration documentation and clarify inline analysis cache behavior * feat: implement panic recovery during analysis to continue scans past errors * feat: add expectations configuration for taint analysis and performance metrics * feat: enhance error handling and logging during file reading and mutex locking * feat: add cross-file body loading tests and plumbing for CF-1 phase * feat: implement cross-file k=1 context-sensitive inline taint analysis with new tests and fixtures * feat: implement indexed-scan parity in cross-file inline analysis with new dropdown and copy functionality * feat: enhance classification span handling in CFG and AST for improved source attribution * feat: add new Express routes for handling user input and telemetry data * feat: implement ternary expression handling in CFG with diamond structure for JS/TS * feat: implement Phase CF-3 abstract-domain transfer channels in summaries * feat: add support for string-prefix transfer in cross-file calls and update tests * docs: reduce RESULTS.md doc size * feat: implement Phase CF-4 per-return-path summary decomposition with tests * feat: update parameter handling in pass1 and refactor SsaFuncSummary initialization * feat: implement Phase CF-5 for cross-file SCC joint fixed-point convergence with new flags and tests * feat: implement Phase CF-6 with parameter-granularity points-to summaries and associated tests * refactor: update comments and documentation for clarity and consistency * style: format code for consistency and readability * refactor: simplify verdict handling and improve edge checking logic * refactor: optimize path and identifier collection by avoiding unnecessary cloning * chore: update Cargo.toml for Rust version 1.85 and add ignored files; modify CHANGELOG and README for clarity on state analysis defaults * refactor: update documentation and improve clarity in configuration files * refactor: update documentation and improve clarity in configuration files * feat: add JS/TS pass-2 convergence tests and expectations configuration * feat: add Phase 5 regression tests for inline cache origin attribution and update related logic * feat: implement Phase 7 deduplication and alternative path linking for taint findings * feat: implement structural DFS index for anonymous functions and update naming conventions * feat: add Phase 8 regression tests for container-element taint in JS and Python * feat: add engine-depth profiles and explain-engine option for CLI * feat: update expectations and add new README fixtures for multi-file scan regression * feat: implement Phase 11 callback-alias and factory patterns with regression tests * feat: implement Terminator::Switch for multi-way dispatch and add regression tests * feat: add real-CVE benchmark fixtures for CVE-2023-48022, CVE-2019-14939, and CVE-2023-26159 with corresponding patched variants * refactor: extract cfg and ssa_transfer to submodules * refactor: cargo fmt * refactor: remove unnecessary blank line in cfg_tests.rs * refactor: remove unnecessary planning file * chore: update Rust version to 1.88 and bump dependencies in Cargo files * feat: enhance triage UI with new layout and controls, update README for clarity * feat: enhance triage UI with new layout and controls, update README for clarity * chore: remove outdated section from README for version 0.5.0 * docs: improve clarity and consistency in README content * chore: add "GPL-3.0-or-later" to license options in about.toml * chore: update license handling in about.toml and check-licenses.mjs * style: format code for improved readability in TriagePage component * style: format code for improved readability in TriagePage component * chore: enhance license handling and improve body_id scoping in seed lookup * feat: introduce owner and parent body IDs for enhanced seed scoping * feat: implement direction-aware engine provenance with new CLI flag for strict CI gating * feat: add Undef SSA operation for improved control-flow handling * style: improve code formatting for consistency and readability in multiple files * feat: add 16-function chain SCC across multiple files for enhanced analysis * style: simplify code formatting for improved readability in multiple files * fix: update CapHitReason default implementation and improve README clarity * docs: enhance README with detailed explanations of taint analysis and limitations * docs: refine README for clarity and consistency in taint analysis section * style: improve code formatting for better readability in NewScanModal and scans * fix: update cargo-about command to use --offline for deterministic license generation * fix: update cargo-about command to use --offline for deterministic license generation * ci: add step to prime cargo registry cache for deterministic license generation * feat: add support for non-sink collections in authorization analysis * feat: enhance authorization checks with row-level ownership equality and binding tracking * feat: implement self-scoped user handling and enhance ownership checks * refactor: simplify assertions and formatting in authorization analysis tests * fix: normalize line endings in THIRDPARTY-LICENSES.html generation and update README with AI disclosure * docs: update AI disclosure section for clarity and conciseness * feat: add AI Contribution Policy and update contributing guidelines for AI assistance disclosure * feat: enhance authorization analysis with SSA-derived variable type classification * feat: implement auth_finding_to_diag function for enhanced security diagnostics * feat: add args_value_refs to CallSite struct for enhanced argument tracking * feat: add args_value_refs to CallSite struct for enhanced argument tracking * feat: add direction-aware engine provenance with LossDirection classification and new CLI flag * feat: simplify strip_cap_from_call_args call by removing unnecessary line breaks * feat: enhance error message handling in cli_validation_tests for better Windows compatibility * feat: optimize release profile settings in Cargo.toml and update CodeQL configuration * feat: enhance release build process with SBOM generation and SLSA provenance * feat: update actions/checkout and actions/setup-node to v6, enhance CLI options, and improve auth-check summaries * feat: introduce PathFact handling for path safety checks and rejection logic * feat: introduce PathFact handling for path safety checks and rejection logic * feat: update benchmark data and enhance path sanitization logic with new safety checks * feat: document AI assistance in frontend UI development and human review process * feat: add return path facts for enhanced path safety checks and update documentation * chore: update release date for version 0.5.0 in CHANGELOG.md * chore: clean up ci.yml by removing outdated comments and clarifying steps * feat: implement cross-language path sanitizers and validators for enhanced security * feat: enhance SSA value usage tracking by including block terminators and improve path safety checks * feat: enhance switch statement handling by adding per-case path constraints and support for exclusive cases * refactor: simplify conditional formatting and improve code readability in executor and lower modules * feat: add vulnerable examples for various languages demonstrating authentication and sanitization issues * feat: enhance actor context recognition for self-actor identifiers and add support for global non-sink receivers * feat: enhance actor context recognition for self-actor identifiers and add support for global non-sink receivers * feat: add transform classifiers for Java, Go, and Ruby with corresponding tests * refactor: clarify comments on reassign-to-constant idiom and sink behavior in guards.rs --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c4ce08b452
commit
41128177d2
2144 changed files with 201812 additions and 8927 deletions
171
frontend/src/components/CopyMarkdownButton.tsx
Normal file
171
frontend/src/components/CopyMarkdownButton.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Status = 'idle' | 'working' | 'copied' | 'failed';
|
||||
|
||||
interface CopyMarkdownButtonProps {
|
||||
getMarkdown: () => string | Promise<string>;
|
||||
label?: string;
|
||||
className?: string;
|
||||
title?: string;
|
||||
stopPropagation?: boolean;
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
const COPIED_MS = 1500;
|
||||
const FAILED_MS = 2000;
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={ICON_SIZE}
|
||||
height={ICON_SIZE}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
||||
<path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={ICON_SIZE}
|
||||
height={ICON_SIZE}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 8.5l3 3 7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FailIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={ICON_SIZE}
|
||||
height={ICON_SIZE}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyMarkdownButton({
|
||||
getMarkdown,
|
||||
label = 'Copy',
|
||||
className,
|
||||
title,
|
||||
stopPropagation,
|
||||
iconOnly,
|
||||
}: CopyMarkdownButtonProps) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current != null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleReset = useCallback((ms: number) => {
|
||||
if (timeoutRef.current != null) window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setStatus('idle');
|
||||
timeoutRef.current = null;
|
||||
}, ms);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
if (status === 'working') return;
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.clipboard ||
|
||||
typeof navigator.clipboard.writeText !== 'function'
|
||||
) {
|
||||
setStatus('failed');
|
||||
scheduleReset(FAILED_MS);
|
||||
return;
|
||||
}
|
||||
setStatus('working');
|
||||
try {
|
||||
const text = await getMarkdown();
|
||||
await navigator.clipboard.writeText(text);
|
||||
setStatus('copied');
|
||||
scheduleReset(COPIED_MS);
|
||||
} catch (err) {
|
||||
console.error('CopyMarkdownButton: failed to copy', err);
|
||||
setStatus('failed');
|
||||
scheduleReset(FAILED_MS);
|
||||
}
|
||||
},
|
||||
[getMarkdown, scheduleReset, status, stopPropagation],
|
||||
);
|
||||
|
||||
const displayLabel =
|
||||
status === 'working'
|
||||
? 'Copying…'
|
||||
: status === 'copied'
|
||||
? 'Copied!'
|
||||
: status === 'failed'
|
||||
? 'Failed'
|
||||
: label;
|
||||
|
||||
const classes = [
|
||||
'btn',
|
||||
'btn-sm',
|
||||
'copy-btn',
|
||||
iconOnly ? 'copy-btn--icon' : '',
|
||||
status === 'copied' ? 'copy-btn--copied' : '',
|
||||
status === 'failed' ? 'copy-btn--failed' : '',
|
||||
className || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const icon =
|
||||
status === 'copied' ? (
|
||||
<CheckIcon />
|
||||
) : status === 'failed' ? (
|
||||
<FailIcon />
|
||||
) : (
|
||||
<CopyIcon />
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classes}
|
||||
title={title ?? (iconOnly ? displayLabel : undefined)}
|
||||
aria-label={iconOnly ? displayLabel : undefined}
|
||||
disabled={status === 'working'}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{iconOnly ? icon : displayLabel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/charts/HorizontalBarChart.tsx
Normal file
84
frontend/src/components/charts/HorizontalBarChart.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
export interface BarItem {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface HorizontalBarChartProps {
|
||||
items: BarItem[];
|
||||
maxValue?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({
|
||||
items,
|
||||
maxValue,
|
||||
width = 400,
|
||||
}: HorizontalBarChartProps) {
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
<p>No data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const barH = 22;
|
||||
const gap = 4;
|
||||
const labelW = 110;
|
||||
const valueW = 45;
|
||||
const barAreaW = width - labelW - valueW - 16;
|
||||
const totalH = items.length * (barH + gap);
|
||||
const maxVal = maxValue ?? Math.max(...items.map((i) => i.value), 1);
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${totalH}`}
|
||||
width="100%"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const y = i * (barH + gap);
|
||||
const w = Math.max((item.value / maxVal) * barAreaW, 2);
|
||||
const color = item.color || 'var(--accent)';
|
||||
return (
|
||||
<g key={item.label}>
|
||||
<text
|
||||
x={labelW - 8}
|
||||
y={y + barH / 2 + 4}
|
||||
textAnchor="end"
|
||||
fontSize={11}
|
||||
fontFamily="var(--font)"
|
||||
fill="var(--text-secondary)"
|
||||
>
|
||||
{item.label}
|
||||
</text>
|
||||
<rect
|
||||
x={labelW}
|
||||
y={y + 2}
|
||||
width={w}
|
||||
height={barH - 4}
|
||||
rx={3}
|
||||
fill={color}
|
||||
opacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={labelW + barAreaW + 8}
|
||||
y={y + barH / 2 + 4}
|
||||
textAnchor="start"
|
||||
fontSize={11}
|
||||
fontFamily="var(--font-mono)"
|
||||
fontWeight={600}
|
||||
fill="var(--text)"
|
||||
>
|
||||
{item.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/charts/LineChart.tsx
Normal file
139
frontend/src/components/charts/LineChart.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { formatShortDate } from '../../utils/formatDate';
|
||||
|
||||
export interface LinePoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface LineChartProps {
|
||||
points: LinePoint[];
|
||||
color?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function LineChart({
|
||||
points,
|
||||
color = 'var(--accent)',
|
||||
width = 400,
|
||||
height = 160,
|
||||
}: LineChartProps) {
|
||||
if (!points || points.length < 2) {
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
<p>Need multiple scans for trends</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pad = { top: 15, right: 15, bottom: 30, left: 40 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
|
||||
const maxVal = Math.max(...points.map((p) => p.value), 1);
|
||||
const minVal = 0;
|
||||
const yRange = maxVal - minVal || 1;
|
||||
|
||||
const xStep = plotW / Math.max(points.length - 1, 1);
|
||||
const coords = points.map((p, i) => ({
|
||||
x: pad.left + i * xStep,
|
||||
y: pad.top + plotH - ((p.value - minVal) / yRange) * plotH,
|
||||
label: p.label,
|
||||
value: p.value,
|
||||
}));
|
||||
|
||||
const polyPoints = coords.map((c) => `${c.x},${c.y}`).join(' ');
|
||||
const areaPoints = `${coords[0].x},${pad.top + plotH} ${polyPoints} ${coords[coords.length - 1].x},${pad.top + plotH}`;
|
||||
|
||||
// Y-axis grid lines
|
||||
const yTicks = 4;
|
||||
const gridLines = [];
|
||||
for (let i = 0; i <= yTicks; i++) {
|
||||
const y = pad.top + (i / yTicks) * plotH;
|
||||
const val = Math.round(maxVal - (i / yTicks) * yRange);
|
||||
gridLines.push({ y, val });
|
||||
}
|
||||
|
||||
// X-axis label sampling
|
||||
const maxLabels = 6;
|
||||
const step = Math.max(1, Math.ceil(coords.length / maxLabels));
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width="100%"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{gridLines.map((g, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={pad.left}
|
||||
y1={g.y}
|
||||
x2={pad.left + plotW}
|
||||
y2={g.y}
|
||||
stroke="var(--border-light)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={pad.left - 6}
|
||||
y={g.y + 3}
|
||||
textAnchor="end"
|
||||
fontSize={9}
|
||||
fontFamily="var(--font-mono)"
|
||||
fill="var(--text-tertiary)"
|
||||
>
|
||||
{g.val}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Area fill */}
|
||||
<polygon points={areaPoints} fill={color} opacity={0.08} />
|
||||
|
||||
{/* Line */}
|
||||
<polyline
|
||||
points={polyPoints}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Dots */}
|
||||
{coords.map((c, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={c.x}
|
||||
cy={c.y}
|
||||
r={3}
|
||||
fill={color}
|
||||
stroke="var(--bg)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{coords.map((c, i) => {
|
||||
if (i % step !== 0 && i !== coords.length - 1) return null;
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={c.x}
|
||||
y={height - 4}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fontFamily="var(--font)"
|
||||
fill="var(--text-tertiary)"
|
||||
>
|
||||
{formatShortDate(c.label)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/data-display/CodeViewer.tsx
Normal file
185
frontend/src/components/data-display/CodeViewer.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiGet } from '../../api/client';
|
||||
import { highlightSyntax, escapeHtml } from '../../utils/syntaxHighlight';
|
||||
import type { FileResponse, ExplorerFinding } from '../../api/types';
|
||||
|
||||
interface LineHighlights {
|
||||
sourceLine?: number;
|
||||
sinkLine?: number;
|
||||
findingLine?: number;
|
||||
}
|
||||
|
||||
interface CodeViewerProps {
|
||||
filePath: string;
|
||||
findings?: ExplorerFinding[];
|
||||
highlights?: LineHighlights;
|
||||
highlightLine?: number;
|
||||
flowLines?: Set<number>;
|
||||
language?: string;
|
||||
className?: string;
|
||||
initialScrollTop?: number;
|
||||
onScrollPositionChange?: (scrollTop: number) => void;
|
||||
}
|
||||
|
||||
export function CodeViewer({
|
||||
filePath,
|
||||
findings,
|
||||
highlights,
|
||||
highlightLine,
|
||||
flowLines,
|
||||
language,
|
||||
className,
|
||||
initialScrollTop,
|
||||
onScrollPositionChange,
|
||||
}: CodeViewerProps) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['files', filePath],
|
||||
queryFn: ({ signal }) =>
|
||||
apiGet<FileResponse>(
|
||||
`/files?path=${encodeURIComponent(filePath)}`,
|
||||
signal,
|
||||
),
|
||||
enabled: !!filePath,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const scrollTarget = highlightLine ?? highlights?.findingLine;
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileData || !scrollTarget || !bodyRef.current) return;
|
||||
const timer = requestAnimationFrame(() => {
|
||||
const target = bodyRef.current?.querySelector(
|
||||
`[data-line="${scrollTarget}"]`,
|
||||
);
|
||||
if (target)
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
});
|
||||
return () => cancelAnimationFrame(timer);
|
||||
}, [fileData, scrollTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!fileData ||
|
||||
scrollTarget ||
|
||||
initialScrollTop == null ||
|
||||
!bodyRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = requestAnimationFrame(() => {
|
||||
if (bodyRef.current) {
|
||||
bodyRef.current.scrollTop = initialScrollTop;
|
||||
}
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(timer);
|
||||
}, [fileData, initialScrollTop, scrollTarget]);
|
||||
|
||||
// Build a set of finding lines for gutter markers
|
||||
const findingsByLine = new Map<number, ExplorerFinding>();
|
||||
if (findings) {
|
||||
for (const f of findings) {
|
||||
// Keep the highest severity per line
|
||||
const existing = findingsByLine.get(f.line);
|
||||
if (
|
||||
!existing ||
|
||||
severityRank(f.severity) > severityRank(existing.severity)
|
||||
) {
|
||||
findingsByLine.set(f.line, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lang = (language || '').toLowerCase();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={className} style={{ padding: 40, textAlign: 'center' }}>
|
||||
Loading file...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="error-state" style={{ padding: 40 }}>
|
||||
<p>
|
||||
Could not load file:{' '}
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!fileData) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`code-viewer-body ${className || ''}`}
|
||||
ref={bodyRef}
|
||||
onScroll={(event) =>
|
||||
onScrollPositionChange?.(event.currentTarget.scrollTop)
|
||||
}
|
||||
>
|
||||
{fileData.lines.map((l) => {
|
||||
let cls = 'code-line';
|
||||
if (highlights) {
|
||||
if (l.number === highlights.sourceLine) cls += ' highlight-source';
|
||||
else if (l.number === highlights.sinkLine) cls += ' highlight-sink';
|
||||
else if (l.number === highlights.findingLine)
|
||||
cls += ' highlight-finding';
|
||||
else if (flowLines?.has(l.number)) cls += ' highlight-flow';
|
||||
} else if (highlightLine && l.number === highlightLine) {
|
||||
cls += ' highlight-finding';
|
||||
}
|
||||
|
||||
const gutterFinding = findingsByLine.get(l.number);
|
||||
|
||||
return (
|
||||
<div key={l.number} className={cls} data-line={l.number}>
|
||||
<span className="line-gutter">
|
||||
{gutterFinding ? (
|
||||
<span
|
||||
className={`gutter-marker sev-${gutterFinding.severity.toLowerCase()}`}
|
||||
title={`${gutterFinding.rule_id}: ${gutterFinding.message || gutterFinding.category}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="gutter-marker-spacer" />
|
||||
)}
|
||||
</span>
|
||||
<span className="line-number">{l.number}</span>
|
||||
<span
|
||||
className="line-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightSyntax(escapeHtml(l.content), lang),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function severityRank(s: string): number {
|
||||
switch (s.toUpperCase()) {
|
||||
case 'HIGH':
|
||||
return 3;
|
||||
case 'MEDIUM':
|
||||
return 2;
|
||||
case 'LOW':
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
155
frontend/src/components/data-display/FileTree.tsx
Normal file
155
frontend/src/components/data-display/FileTree.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { FolderIcon } from '../icons/Icons';
|
||||
import type { TreeEntry } from '../../api/types';
|
||||
|
||||
interface FileTreeProps {
|
||||
entries: TreeEntry[];
|
||||
expandedPaths: Set<string>;
|
||||
selectedPath: string | null;
|
||||
onToggleExpand: (path: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
loadedChildren: Map<string, TreeEntry[]>;
|
||||
}
|
||||
|
||||
export function FileTree({
|
||||
entries,
|
||||
expandedPaths,
|
||||
selectedPath,
|
||||
onToggleExpand,
|
||||
onSelectFile,
|
||||
loadedChildren,
|
||||
}: FileTreeProps) {
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{entries.map((entry) => (
|
||||
<FileTreeNode
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
depth={0}
|
||||
expandedPaths={expandedPaths}
|
||||
selectedPath={selectedPath}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelectFile={onSelectFile}
|
||||
loadedChildren={loadedChildren}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileTreeNodeProps {
|
||||
entry: TreeEntry;
|
||||
depth: number;
|
||||
expandedPaths: Set<string>;
|
||||
selectedPath: string | null;
|
||||
onToggleExpand: (path: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
loadedChildren: Map<string, TreeEntry[]>;
|
||||
}
|
||||
|
||||
function FileTreeNode({
|
||||
entry,
|
||||
depth,
|
||||
expandedPaths,
|
||||
selectedPath,
|
||||
onToggleExpand,
|
||||
onSelectFile,
|
||||
loadedChildren,
|
||||
}: FileTreeNodeProps) {
|
||||
const isDir = entry.entry_type === 'dir';
|
||||
const isExpanded = expandedPaths.has(entry.path);
|
||||
const isSelected = selectedPath === entry.path;
|
||||
const children = loadedChildren.get(entry.path);
|
||||
|
||||
const sevClass =
|
||||
entry.finding_count > 0 && entry.severity_max
|
||||
? ` sev-${entry.severity_max.toLowerCase()}`
|
||||
: '';
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDir) {
|
||||
onToggleExpand(entry.path);
|
||||
} else {
|
||||
onSelectFile(entry.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`tree-node${isSelected ? ' selected' : ''}${sevClass}`}
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className={`tree-chevron${isDir ? '' : ' invisible'}`}>
|
||||
{isDir ? (isExpanded ? '▾' : '▸') : ''}
|
||||
</span>
|
||||
<span className="tree-node-icon">
|
||||
{isDir ? (
|
||||
<FolderIcon size={14} />
|
||||
) : (
|
||||
<FileIcon language={entry.language} />
|
||||
)}
|
||||
</span>
|
||||
<span className="tree-node-name" title={entry.path}>
|
||||
{entry.name}
|
||||
</span>
|
||||
{entry.finding_count > 0 && (
|
||||
<span className="tree-node-badge">{entry.finding_count}</span>
|
||||
)}
|
||||
</div>
|
||||
{isDir && isExpanded && children && (
|
||||
<div className="tree-children">
|
||||
{children.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.path}
|
||||
entry={child}
|
||||
depth={depth + 1}
|
||||
expandedPaths={expandedPaths}
|
||||
selectedPath={selectedPath}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelectFile={onSelectFile}
|
||||
loadedChildren={loadedChildren}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ language }: { language?: string }) {
|
||||
const label = (language || '').charAt(0).toUpperCase() || '·';
|
||||
const color = langColor(language);
|
||||
return (
|
||||
<span className="file-icon" style={{ color }} title={language || 'file'}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function langColor(lang?: string): string {
|
||||
switch (lang?.toLowerCase()) {
|
||||
case 'javascript':
|
||||
return '#f0db4f';
|
||||
case 'typescript':
|
||||
return '#3178c6';
|
||||
case 'python':
|
||||
return '#3572a5';
|
||||
case 'rust':
|
||||
return '#dea584';
|
||||
case 'go':
|
||||
return '#00add8';
|
||||
case 'java':
|
||||
return '#b07219';
|
||||
case 'ruby':
|
||||
return '#cc342d';
|
||||
case 'php':
|
||||
return '#4f5d95';
|
||||
case 'c':
|
||||
return '#555555';
|
||||
case 'c++':
|
||||
return '#f34b7d';
|
||||
default:
|
||||
return 'var(--text-tertiary)';
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/explorer/AnalysisWorkspace.tsx
Normal file
35
frontend/src/components/explorer/AnalysisWorkspace.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
interface AnalysisWorkspaceProps {
|
||||
canvas: ReactNode;
|
||||
inspector?: ReactNode;
|
||||
inspectorTitle?: string;
|
||||
inspectorSide?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function AnalysisWorkspace({
|
||||
canvas,
|
||||
inspector,
|
||||
inspectorTitle,
|
||||
inspectorSide = 'right',
|
||||
}: AnalysisWorkspaceProps) {
|
||||
const hasInspector = Boolean(inspector);
|
||||
const inspectorPanel = hasInspector ? (
|
||||
<aside className="analysis-inspector">
|
||||
{inspectorTitle && <h3>{inspectorTitle}</h3>}
|
||||
{inspector}
|
||||
</aside>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`analysis-workspace${hasInspector ? ' analysis-workspace-with-inspector' : ''}${
|
||||
hasInspector ? ` analysis-workspace-inspector-${inspectorSide}` : ''
|
||||
}`}
|
||||
>
|
||||
{inspectorSide === 'left' && inspectorPanel}
|
||||
<div className="analysis-canvas">{canvas}</div>
|
||||
{inspectorSide === 'right' && inspectorPanel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/icons/Icons.tsx
Normal file
170
frontend/src/components/icons/Icons.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import type { FC, SVGProps } from 'react';
|
||||
|
||||
export interface IconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
type SvgBaseProps = SVGProps<SVGSVGElement> & IconProps;
|
||||
|
||||
function svgProps({ className, size = 18 }: IconProps): SvgBaseProps {
|
||||
return {
|
||||
className,
|
||||
width: size,
|
||||
height: size,
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
};
|
||||
}
|
||||
|
||||
export function OverviewIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<rect x="2" y="2" width="5.5" height="5.5" rx="1" />
|
||||
<rect x="10.5" y="2" width="5.5" height="5.5" rx="1" />
|
||||
<rect x="2" y="10.5" width="5.5" height="5.5" rx="1" />
|
||||
<rect x="10.5" y="10.5" width="5.5" height="5.5" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<path d="M9 2L2 6v5c0 3.5 3 6 7 7 4-1 7-3.5 7-7V6L9 2z" />
|
||||
<path d="M9 6v4" />
|
||||
<circle cx="9" cy="12.5" r="0.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScansIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<path d="M14.5 9A5.5 5.5 0 1 1 9 3.5" />
|
||||
<polyline points="9 5 9 9 12 11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RulesIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<path d="M4 5h10" />
|
||||
<path d="M4 9h10" />
|
||||
<path d="M4 13h10" />
|
||||
<polyline points="2 4.5 2.8 5.5 4 4" />
|
||||
<polyline points="2 8.5 2.8 9.5 4 8" />
|
||||
<polyline points="2 12.5 2.8 13.5 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriageIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<path d="M10 2L4 3v9l6 4 6-4V3l-6-1z" />
|
||||
<path d="M10 6v4" />
|
||||
<circle cx="10" cy="12.5" r="0.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<line x1="3" y1="5" x2="15" y2="5" />
|
||||
<line x1="3" y1="9" x2="15" y2="9" />
|
||||
<line x1="3" y1="13" x2="15" y2="13" />
|
||||
<circle cx="6" cy="5" r="1.5" fill="var(--bg-secondary)" />
|
||||
<circle cx="11" cy="9" r="1.5" fill="var(--bg-secondary)" />
|
||||
<circle cx="7" cy="13" r="1.5" fill="var(--bg-secondary)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExplorerIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<path d="M3 3v12h12" />
|
||||
<path d="M7 3v4h4V3" />
|
||||
<path d="M7 11v4h4v-4" />
|
||||
<path d="M11 7h4v4h-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<polyline points="4 5 2 5 2 16 13 16 13 14" />
|
||||
<polyline points="6 2 16 2 16 12 6 12 6 2" />
|
||||
<path d="M9 5.5h4" />
|
||||
<path d="M9 8h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsIcon({ className, size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
|
||||
<circle cx="9" cy="9" r="2.5" />
|
||||
<path d="M9 1.5v2M9 14.5v2M1.5 9h2M14.5 9h2M3.7 3.7l1.4 1.4M12.9 12.9l1.4 1.4M14.3 3.7l-1.4 1.4M5.1 12.9l-1.4 1.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderIcon({ className, size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2 3.5C2 2.95 2.45 2.5 3 2.5h2.5l1.5 1.5H11c.55 0 1 .45 1 1v5.5c0 .55-.45 1-1 1H3c-.55 0-1-.45-1-1V3.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagIcon({ className, size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1.5 7.8V2.5c0-.6.4-1 1-1h5.3L13 6.7l-5.3 5.3L1.5 7.8z" />
|
||||
<circle cx="5" cy="5" r="0.8" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map of icon name to component, for dynamic lookup */
|
||||
export const ICONS: Record<string, FC<IconProps>> = {
|
||||
overview: OverviewIcon,
|
||||
findings: FindingsIcon,
|
||||
scans: ScansIcon,
|
||||
rules: RulesIcon,
|
||||
triage: TriageIcon,
|
||||
config: ConfigIcon,
|
||||
explorer: ExplorerIcon,
|
||||
debug: DebugIcon,
|
||||
settings: SettingsIcon,
|
||||
folder: FolderIcon,
|
||||
tag: TagIcon,
|
||||
};
|
||||
67
frontend/src/components/layout/AppLayout.tsx
Normal file
67
frontend/src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { HeaderBar } from './HeaderBar';
|
||||
import { NewScanModal } from '../../modals/NewScanModal';
|
||||
import { OverviewPage } from '../../pages/OverviewPage';
|
||||
import { FindingsPage } from '../../pages/FindingsPage';
|
||||
import { FindingDetailPage } from '../../pages/FindingDetailPage';
|
||||
import { ScansPage } from '../../pages/ScansPage';
|
||||
import { ScanDetailPage } from '../../pages/ScanDetailPage';
|
||||
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';
|
||||
import { SummaryExplorerPage } from '../../pages/debug/SummaryExplorerPage';
|
||||
|
||||
export function AppLayout() {
|
||||
const [scanModalOpen, setScanModalOpen] = useState(false);
|
||||
|
||||
const handleStartScan = useCallback(() => {
|
||||
setScanModalOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div id="app">
|
||||
<Sidebar />
|
||||
<div className="main-panel">
|
||||
<HeaderBar onStartScan={handleStartScan} />
|
||||
<main className="content">
|
||||
<Routes>
|
||||
<Route path="/" element={<OverviewPage />} />
|
||||
<Route path="/findings" element={<FindingsPage />} />
|
||||
<Route path="/findings/:id" element={<FindingDetailPage />} />
|
||||
<Route path="/scans" element={<ScansPage />} />
|
||||
<Route
|
||||
path="/scans/compare/:left/:right"
|
||||
element={<ScanComparePage />}
|
||||
/>
|
||||
<Route path="/scans/:id" element={<ScanDetailPage />} />
|
||||
<Route path="/rules" element={<RulesPage />} />
|
||||
<Route path="/rules/:id" element={<RulesPage />} />
|
||||
<Route path="/triage" element={<TriagePage />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/explorer" element={<ExplorerPage />} />
|
||||
<Route path="/debug" element={<DebugLayout />}>
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to="/debug/call-graph" replace />}
|
||||
/>
|
||||
<Route path="call-graph" element={<CallGraphPage />} />
|
||||
<Route path="summaries" element={<SummaryExplorerPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<StubPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
<NewScanModal
|
||||
open={scanModalOpen}
|
||||
onClose={() => setScanModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/layout/HeaderBar.tsx
Normal file
90
frontend/src/components/layout/HeaderBar.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
const SECTION_TITLES: Record<string, string> = {
|
||||
overview: 'Overview',
|
||||
findings: 'Findings',
|
||||
scans: 'Scans',
|
||||
rules: 'Rules',
|
||||
triage: 'Triage',
|
||||
config: 'Config',
|
||||
explorer: 'Explorer',
|
||||
debug: 'Debug',
|
||||
settings: 'Settings',
|
||||
};
|
||||
|
||||
const ROUTE_TITLES: Record<string, string> = {
|
||||
'/debug/cfg': 'CFG Viewer',
|
||||
'/debug/ssa': 'SSA Viewer',
|
||||
'/debug/call-graph': 'Call Graph',
|
||||
'/debug/taint': 'Taint Debugger',
|
||||
};
|
||||
|
||||
function pathToSection(pathname: string): string {
|
||||
if (pathname === '/') return 'overview';
|
||||
const first = pathname.split('/')[1];
|
||||
return first || 'overview';
|
||||
}
|
||||
|
||||
function buildBreadcrumbs(pathname: string) {
|
||||
const section = pathToSection(pathname);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
interface HeaderBarProps {
|
||||
onStartScan?: () => void;
|
||||
}
|
||||
|
||||
export function HeaderBar({ onStartScan }: HeaderBarProps) {
|
||||
const { pathname } = useLocation();
|
||||
const crumbs = buildBreadcrumbs(pathname);
|
||||
|
||||
return (
|
||||
<header className="header-bar">
|
||||
<div className="header-left">
|
||||
<nav className="breadcrumbs">
|
||||
{crumbs.map((crumb, i) => {
|
||||
const isLast = i === crumbs.length - 1;
|
||||
return (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="breadcrumb-sep">/</span>}
|
||||
{isLast || !crumb.path ? (
|
||||
<span className="breadcrumb-current">{crumb.label}</span>
|
||||
) : (
|
||||
<Link to={crumb.path} className="breadcrumb-link">
|
||||
{crumb.label}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{onStartScan && (
|
||||
<button className="btn btn-primary btn-sm" onClick={onStartScan}>
|
||||
Start Scan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
178
frontend/src/components/layout/Sidebar.tsx
Normal file
178
frontend/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
OverviewIcon,
|
||||
FindingsIcon,
|
||||
ScansIcon,
|
||||
RulesIcon,
|
||||
TriageIcon,
|
||||
ConfigIcon,
|
||||
ExplorerIcon,
|
||||
DebugIcon,
|
||||
SettingsIcon,
|
||||
FolderIcon,
|
||||
TagIcon,
|
||||
} from '../icons/Icons';
|
||||
import type { FC } from 'react';
|
||||
import type { IconProps } from '../icons/Icons';
|
||||
import { useHealth } from '../../api/queries/health';
|
||||
import { useSSE } from '../../contexts/SSEContext';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
Icon: FC<IconProps>;
|
||||
group: 'primary' | 'secondary' | 'footer';
|
||||
}
|
||||
|
||||
const NAV_SECTIONS: NavItem[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
path: '/',
|
||||
Icon: OverviewIcon,
|
||||
group: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'findings',
|
||||
label: 'Findings',
|
||||
path: '/findings',
|
||||
Icon: FindingsIcon,
|
||||
group: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'scans',
|
||||
label: 'Scans',
|
||||
path: '/scans',
|
||||
Icon: ScansIcon,
|
||||
group: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
label: 'Rules',
|
||||
path: '/rules',
|
||||
Icon: RulesIcon,
|
||||
group: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'triage',
|
||||
label: 'Triage',
|
||||
path: '/triage',
|
||||
Icon: TriageIcon,
|
||||
group: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: 'Config',
|
||||
path: '/config',
|
||||
Icon: ConfigIcon,
|
||||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
label: 'Explorer',
|
||||
path: '/explorer',
|
||||
Icon: ExplorerIcon,
|
||||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'debug',
|
||||
label: 'Debug',
|
||||
path: '/debug',
|
||||
Icon: DebugIcon,
|
||||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
path: '/settings',
|
||||
Icon: SettingsIcon,
|
||||
group: 'footer',
|
||||
},
|
||||
];
|
||||
|
||||
function navLinkClass({ isActive }: { isActive: boolean }) {
|
||||
return `nav-link${isActive ? ' active' : ''}`;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { data: health } = useHealth();
|
||||
const { isScanRunning } = useSSE();
|
||||
|
||||
const primary = NAV_SECTIONS.filter((n) => n.group === 'primary');
|
||||
const secondary = NAV_SECTIONS.filter((n) => n.group === 'secondary');
|
||||
const footer = NAV_SECTIONS.filter((n) => n.group === 'footer');
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<span className="logo">nyx</span>
|
||||
{health?.version && <span className="version">v{health.version}</span>}
|
||||
</div>
|
||||
|
||||
<ul className="nav-list">
|
||||
{primary.map((item) => (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={item.path === '/'}
|
||||
className={navLinkClass}
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<item.Icon />
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className="nav-separator" />
|
||||
|
||||
{secondary.map((item) => (
|
||||
<li key={item.id}>
|
||||
<NavLink to={item.path} className={navLinkClass}>
|
||||
<span className="nav-icon">
|
||||
<item.Icon />
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<ul className="nav-list" style={{ flex: 'none' }}>
|
||||
{footer.map((item) => (
|
||||
<li key={item.id}>
|
||||
<NavLink to={item.path} className={navLinkClass}>
|
||||
<span className="nav-icon">
|
||||
<item.Icon />
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-meta">
|
||||
{health?.scan_root && (
|
||||
<div className="sidebar-meta-item" title={health.scan_root}>
|
||||
<FolderIcon />
|
||||
<span>{health.scan_root}</span>
|
||||
</div>
|
||||
)}
|
||||
{health?.version && (
|
||||
<div className="sidebar-meta-item">
|
||||
<TagIcon />
|
||||
<span>v{health.version}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`scan-indicator${isScanRunning ? ' visible' : ''}`}>
|
||||
<span className="status-dot running" />
|
||||
Scanning...
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/ui/Dropdown.tsx
Normal file
103
frontend/src/components/ui/Dropdown.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: (opts: { open: boolean }) => ReactNode;
|
||||
children: (opts: { close: () => void }) => ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
trigger,
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
}: DropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handlePointer = (e: MouseEvent) => {
|
||||
if (!rootRef.current) return;
|
||||
if (!rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointer);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointer);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`dropdown${open ? ' dropdown--open' : ''}${className ? ` ${className}` : ''}`}
|
||||
>
|
||||
<div
|
||||
className="dropdown-trigger"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger({ open })}
|
||||
</div>
|
||||
{open && (
|
||||
<div className={`dropdown-menu dropdown-menu--${align}`} role="menu">
|
||||
{children({ close })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownItemProps {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
checked?: boolean;
|
||||
hint?: string;
|
||||
tone?: 'default' | 'warning';
|
||||
}
|
||||
|
||||
export function DropdownItem({
|
||||
onClick,
|
||||
children,
|
||||
checked,
|
||||
hint,
|
||||
tone = 'default',
|
||||
}: DropdownItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`dropdown-item dropdown-item--${tone}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="dropdown-item-check" aria-hidden>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
<span className="dropdown-item-label">{children}</span>
|
||||
{hint && <span className="dropdown-item-hint">{hint}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/ui/EmptyState.tsx
Normal file
17
frontend/src/components/ui/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
message?: string;
|
||||
children?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ message, children, icon }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
{icon && <div className="empty-state-icon">{icon}</div>}
|
||||
{message && <p>{message}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/ui/ErrorState.tsx
Normal file
13
frontend/src/components/ui/ErrorState.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
interface ErrorStateProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ErrorState({ title = 'Error', message }: ErrorStateProps) {
|
||||
return (
|
||||
<div className="error-state">
|
||||
<h3>{title}</h3>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/ui/LoadingState.tsx
Normal file
7
frontend/src/components/ui/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function LoadingState({ message = 'Loading...' }: LoadingStateProps) {
|
||||
return <div className="loading">{message}</div>;
|
||||
}
|
||||
38
frontend/src/components/ui/Modal.tsx
Normal file
38
frontend/src/components/ui/Modal.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, className, children }: ModalProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, handleKeyDown]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={className || 'code-modal-overlay'}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/ui/Pagination.tsx
Normal file
75
frontend/src/components/ui/Pagination.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
interface PaginationProps {
|
||||
page: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPerPageChange?: (perPage: number) => void;
|
||||
}
|
||||
|
||||
const PER_PAGE_OPTIONS = [25, 50, 100];
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
onPageChange,
|
||||
onPerPageChange,
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.ceil(total / perPage) || 1;
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
<div className="pagination-left">
|
||||
<span>Per page:</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={(e) => onPerPageChange?.(Number(e.target.value))}
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pagination-center">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(1)}
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pagination-right">
|
||||
<span>{total} total</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/ui/StatCard.tsx
Normal file
32
frontend/src/components/ui/StatCard.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
delta?: number | null;
|
||||
color?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
color,
|
||||
subtitle,
|
||||
}: StatCardProps) {
|
||||
const colorStyle = color ? { color } : undefined;
|
||||
|
||||
return (
|
||||
<div className="overview-stat-card">
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className="stat-value" style={colorStyle}>
|
||||
{value}
|
||||
{delta != null && delta !== 0 && (
|
||||
<span className={`stat-delta delta-${delta > 0 ? 'up' : 'down'}`}>
|
||||
{delta > 0 ? '\u25B2' : '\u25BC'} {Math.abs(delta)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <div className="stat-subtitle">{subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue