Merge branch 'master' into dynamic

This commit is contained in:
elipeter 2026-05-11 12:44:12 -04:00
commit 7e0e19a7f0
356 changed files with 14144 additions and 1317 deletions

3
.gitignore vendored
View file

@ -13,3 +13,6 @@
.z3-trace
.pitboss
.node_modules-target
node_modules
__pycache__/
*.pyc

View file

@ -4,10 +4,53 @@ All notable changes to Nyx are documented here. The format is based on [Keep a C
## [Unreleased]
A round of cross-file FastAPI auth, two new sink/validator classes, a ~957-FP Go DAO helper precision pass, four CVE corpus pairs, and a performance pass on the auth extractor pipeline plus SCCP and the global summaries hash map.
A round of cross-file FastAPI auth, two new sink/validator classes, a ~957-FP Go DAO helper precision pass, four CVE corpus pairs, a local web UI visual refresh, and a performance pass on the auth extractor pipeline plus SCCP and the global summaries hash map.
This branch also adds seven new vulnerability classes (LDAP injection, XPath injection, header / CRLF injection, open redirect, server-side template injection, XXE, prototype pollution), a `nyx rules` CLI subcommand, two SSA configuration sidecars (XML parser hardening, XPath variable resolver), two new path-state predicates for inline open-redirect sanitisers, and a flow-sensitive `Object.create(null)` recogniser for prototype-pollution suppression.
### Detector classes
- New `Cap` bits and canonical rule ids: `Cap::LDAP_INJECTION` / `taint-ldap-injection`, `Cap::XPATH_INJECTION` / `taint-xpath-injection`, `Cap::HEADER_INJECTION` / `taint-header-injection`, `Cap::OPEN_REDIRECT` / `taint-open-redirect`, `Cap::SSTI` / `taint-template-injection`, `Cap::XXE` / `taint-xxe`, `Cap::PROTOTYPE_POLLUTION` / `taint-prototype-pollution`. Each ships with per-language sink, sanitizer, and (where applicable) gated-sink rules across JS/TS, Python, Java, PHP, Go, Ruby, Rust, and C/C++. Severity, OWASP 2021 mapping, and human-readable description live in a single `CAP_RULE_REGISTRY` table in `src/labels/mod.rs`; `cap_rule_meta()` and `rule_id_for_caps()` are the public lookups.
- `Cap` widened from `u16` to `u32` to fit the new bits. `Evidence.sink_caps` is now `u32`; `RuleInfo.cap_bits` is also `u32`. The serde decoder accepts any unsigned integer width so caches written before the bump still load. SQLite schema bumped 3 to 4 to force a rescan, since older `source_caps` / `sanitizer_caps` / `sink_caps` blobs were emitted before any of the new bits could appear.
- `owasp_bucket_for` consults `CAP_RULE_REGISTRY` first so adding a new cap class does not require a second-table edit. The match requires an exact rule id or a recognised separator (` `, `(`, `.`) so a future `taint-ssrf-allowlist-violation` can no longer silently inherit `taint-ssrf`'s OWASP bucket. The legacy family-token table now also routes `xpath`, `header`, and `xxe` to A03 / A05.
- `issue_category_label` (dashboard badge) routes the seven new rule-id prefixes to dedicated labels: LDAP Injection, XPath Injection, Header Injection, Open Redirect, Template Injection, XXE, Prototype Pollution.
### Changed
- Refreshed the local web UI visual system around the mint-cyan Nyx brand: warmer light surfaces, deep green accents, updated severity/confidence colors, tighter typography, smaller radii, denser cards, table, badge, button, header, and sidebar styling, and matched graph/code-viewer colors.
- Reworked the main `nyx serve` surfaces for a more operational layout. Overview now uses the refreshed health-score card and chart grid; Scans has a fixed compact table with capped language badges; Scan Detail places summary and timing data side by side; Triage, Rules, Config, Explorer, Finding Detail, Scan Compare, and Debug pages received focused spacing, overflow, and density fixes.
- Updated the SPA and embedded server bundle to share branded assets: PNG favicons, Apple touch icon, sidebar logo image, refreshed SVG favicon, and Rust static handlers for the new `/logo.png` and favicon files.
- Regenerated README/docs screenshots and GIFs against the new UI treatment at 1600x992, saving raw originals before framing and adding CLI GIF plus combined CLI-to-serve demo GIF capture support.
- Extended the screenshot capture workflow with mint-led framing copy, optional `nyxscan.dev` asset mirroring, WebP regeneration for mirrored PNGs, and raw `_raw` image/GIF outputs for downstream reuse.
### Added
- `nyx rules list` CLI subcommand. Surfaces the same registry the dashboard's `/api/rules` page reads from: built-in cap-class entries (one per `Cap` with a canonical rule id), per-language label rules (sink / source / sanitizer), gated sinks, and any custom rules from config. Filters: `--lang <slug>`, `--kind <class|source|sink|sanitizer>`, `--class-only` for registry entries only, `--no-class` for per-language rules only. `--json` for machine output. Cap-class entries carry `language = "all"` so a language filter still surfaces them unless `--no-class` is set.
- `RuleInfo.is_class` / `RuleInfo.emission_active` flags. Cap-class entries carry `is_class = true` so dashboards can group them separately from per-language label rules. `emission_active = false` marks legacy classes (SQL_QUERY, SSRF, FILE_IO, FMT_STRING, DESERIALIZE, CODE_EXEC, CRYPTO) whose findings still surface under the catch-all `taint-unsanitised-flow` rule id; the seven new classes plus `unauthorized_id` and `data_exfil` are `emission_active = true`. The active set is pinned in `cap_rule_registry_emission_active_set_is_pinned` so a future migration of a legacy cap to its specific rule id can't drift silently.
- XML-parser configuration tracking. New `src/ssa/xml_config.rs` runs alongside type-fact analysis and carries per-receiver `secure_processing` / `disallow_doctype` / `external_entities` flags forward through copy assignments and phi joins (meet for safe flags, sticky union for the unsafe `external_entities` polarity). `xxe_safe()` queries the result at the type-qualified `XmlParser.parse` sink and strips `Cap::XXE` when the parser was provably hardened (JAXP `setFeature(FEATURE_SECURE_PROCESSING, true)`, lxml `XMLParser(resolve_entities=False, no_network=True)`, fast-xml-parser `processEntities: false`). Persisted to `OptimizeResult.xml_parser_config`.
- XPath-receiver configuration tracking. New `src/ssa/xpath_config.rs` mirrors the XML sidecar for Java's `XPath` instances: `setXPathVariableResolver(...)` flips the receiver's `has_resolver` flag, copy assignments union, phi joins meet. `xpath_safe()` strips `Cap::XPATH_INJECTION` at `xpath.evaluate(expr, ...)` / `xpath.compile(expr)` sinks when the receiver was provably bound to a resolver (parameterised XPath shape). Persisted to `OptimizeResult.xpath_config`.
- Five new `TypeKind` variants: `LdapClient` (JNDI `InitialDirContext` / `InitialLdapContext`, Spring `LdapTemplate`, ldapjs `createClient`, python-ldap `initialize`, ldap3 `Connection`), `XPathClient` (JAXP `newXPath`, lxml `etree.XPath`, npm `xpath`), `XmlParser` (JAXP factory products: `newDocumentBuilder`, `newSAXParser`, `getXMLReader`), `Template` (Apache FreeMarker `new Template(...)` / `Configuration.getTemplate`), and `NullPrototypeObject` for JS/TS values produced by `Object.create(null)`. Each is wired into `constructor_type` for return-type inference and into `TypeKind::label_prefix()` for type-qualified callee resolution. `XPathClient` is kept distinct from `DatabaseConnection` so a generic `pdo->query` SQL_QUERY sink does not collide with `xpath.query`.
- `GateActivation::LiteralOnly`. Strict literal-value activation: the gate fires only when the activation argument is a literal that matches `dangerous_values` / `dangerous_prefixes`. Unknown or dynamic activation argument suppresses (no conservative `ALL_ARGS_PAYLOAD` push). Used for ambiguously named matchers where the dangerous shape is identifiable only by an explicit literal flag, e.g. bare `extend` where `jQuery.extend(true, target, src)` is the deep-merge prototype-pollution form but Backbone's `Model.extend({proto})` shares the suffix.
- Two new `PredicateKind` variants in `src/taint/path_state.rs` for inline open-redirect sanitisers. `RelativeUrlValidated` covers `x.startsWith("/")`, `x.starts_with("/")`, `x.startswith("/")`, PHP `strpos($x, "/") === 0`, and direct `x[0] === "/"`. `HostAllowlistValidated` covers `new URL(x).host === ALLOWED`, `urlparse(x).netloc == ALLOWED`, multi-statement `parsed.host_str() == "..."` for Rust, and `parsed.Host == "..."` / `parsed.Hostname() == "..."` for Go. Both are cap-aware: they clear `Cap::OPEN_REDIRECT` only on the validated branch, leaving any non-redirect taint downstream to fire on its own caps. The Go form gates on case-sensitive capital `H` so a lowercase `u.host == X` field comparison falls through to the generic `Comparison` predicate.
- `Object.create(null)` recogniser. New `is_object_create_null_call` in `cfg/literals.rs` matches `Object.create(null)` (and parenthesised, awaited, or TS type-cast wrappers) and tags `CallMeta.produces_null_proto = true` for JS/TS calls. Type-fact analysis lifts the flag to `TypeKind::NullPrototypeObject` on the returned SSA value so the synthetic `__index_set__` sink is suppressed flow-sensitively. Phi joins drop the tag back to `Unknown` so a partial null-proto receiver still fires on the unsafe path.
- CFG-layer prototype-pollution suppression on the synthetic `__index_set__` sink (JS/TS only, recognised by the existing `try_lower_subscript_write` lowering). Three flow-insensitive shapes elide the `Sink(PROTOTYPE_POLLUTION)` label before SSA sees the node: constant-key fold (literal key not in `__proto__` / `constructor` / `prototype`); reject pattern (an enclosing-block sibling `if (idx === "__proto__" || ...) return / throw / break;`); allowlist pattern (an ancestor `if (idx === "name" || idx === "id") { obj[idx] = v }`). Walks stop at the enclosing function so closure-captured guards in an outer scope can't silently authorise inner assignments.
- Spring MVC `return "redirect:" + tainted` open-redirect recogniser (Java only). New `try_lower_spring_redirect_return` in `cfg/mod.rs` matches the leftmost `+`-chain whose root is a `redirect:` string literal and emits a synthetic `__spring_redirect__` Call sink with `Sink(Cap::OPEN_REDIRECT)` between the predecessors and the Return node. Concatenated identifiers from anywhere in the right-hand chain feed the synthetic node's `arg_uses[0]`, so the existing taint pipeline carries any tainted suffix through OPEN_REDIRECT.
- Subscript-set form classification for header sinks. `response.headers["X-Foo"] = bar` / `headers["X-Foo"] = bar` (Ruby `element_reference`, JS/TS `subscript_expression`, Python `subscript`) had no `property` field on the LHS, so the existing classification path skipped it. `push_node` now walks into the subscript's `object` and classifies its member-expression text (`response.headers`, `res.headers`, `self.response.headers`), so `Cap::HEADER_INJECTION` fires on the bare bracket form alongside `setHeader` / `res.set` / `headers_mut.insert`.
- PHP literal extraction extended in `cfg/literals.rs`. `extract_const_string_arg` now folds: PHP `encapsed_string` (double-quoted) when every child is a pure-literal segment; boolean literals (`true` / `false`) so jQuery's `extend(true, target, src)` deep-merge marker activates the `LiteralOnly` gate; leading-string `binary_expression` concat (PHP `"Location: " . $url`, JS/TS `"Location: " + url`) so `dangerous_prefixes` matching activates on partially dynamic concatenations.
- PHP receiver-text strip for chain construction. `helpers::root_receiver_text` now drops the leading `$` from `variable_name` nodes so `$smarty->fetch(...)` / `$twig->createTemplate(...)` reconstruct as `Smarty.fetch` / `Environment.createTemplate` for suffix-matcher gates instead of carrying a `$smarty.fetch` form that fails the boundary rule.
- Gate-callee resolution hardening for member-source rewrites. When `first_member_label` rewrites a call's `text` to a Source like `req.body` (because the wrapper carries a member-source argument), the gate matcher now reads the call's `function` / `method` / `name` field instead, so `setValue(target, req.body, ...)` matches the `setValue` proto-pollution gate instead of the rewritten `req.body` text. Whitespace stripped from the function field so multi-line chains still match flat gate matchers.
- Ruby option-constant lookup in gate activation. Bare `scope_resolution` / `constant` nodes (`Nokogiri::XML::ParseOptions::NOENT`) now fall back to the macro-arg extractor used by C/C++/PHP, so Nokogiri XXE gates activate on idiomatic option-flag arguments rather than firing conservatively on every positional arg.
- Per-language label rules expanded to cover the seven new caps:
- JavaScript / TypeScript: ldapjs `LdapClient.search`, `escapeXpath` / `xpathEscape`, `document.evaluate` / npm `xpath.select`, `setHeader` / `res.set` / `res.append` / `res.headers[]=`, `stripCRLF` / `escapeHeader`, lodash / dot-prop / object-path deep-merge prototype-pollution gates, Handlebars / EJS / Mustache template sinks, fast-xml-parser / xml2js with `processEntities`-aware activation, `redirect` / `Location` open-redirect sinks.
- Python: python-ldap `LDAPObject.search_s`, ldap3 `Connection.search`, lxml `etree.XPath` / `lxml.etree.parse` with parser-config awareness, Flask `response.headers[]=` / `make_response`, Jinja2 `Template(...)` and Mako `Template(...)` SSTI sinks, `flask.redirect` / `aiohttp HTTPFound` open-redirect.
- Java / Kotlin: `DirContext.search`, `XPath.evaluate` / `XPath.compile`, JAXP `DocumentBuilder.parse` / `SAXParser.parse` / `XMLReader.parse`, FreeMarker `Template.process`, Spring `redirect:` view-name synthetic sink, `HttpServletResponse.setHeader` / `addHeader`.
- PHP: `ldap_search` / `ldap_list` / `ldap_read`, `DOMXPath::query` / `DOMXPath::evaluate`, `header()` with leading-prefix activation, Smarty `fetch` / Twig `createTemplate` / Blade compile + `eval` template forms, `loadXML` / `simplexml_load_string` with `LIBXML_NOENT` activation.
- Go: `go-ldap conn.Search`, `etree.Path` / `xmlpath.Compile`, `http.Header.Set` / `Response.Header().Set`, `html/template` and `text/template` `Parse(...)`, `encoding/xml.Unmarshal` / `Decoder.Decode`, `http.Redirect` with relative-URL / host-allowlist gating.
- Ruby: `Net::LDAP#search`, `Nokogiri::XML::Document#xpath`, `response.headers[]=`, `ERB.new` SSTI, `Nokogiri::XML.parse` with `NOENT` / `DTDLOAD` activation, `redirect_to` with relative-URL gate.
- C / C++: libldap `ldap_search_ext_s`, libxml2 `xmlXPathEval`, `curl_easy_setopt` with header-list activation, libxml2 `xmlReadFile` / `xmlReadMemory` with `XML_PARSE_NOENT` activation.
- Rust: actix-web `HeaderMap.insert` / `HeaderValue::from_str` header-injection gates. `Redirect::to` retagged from `Cap::SSRF` to `Cap::OPEN_REDIRECT` so the open-redirect rule fires distinctly from the SSRF rule.
- `NYX_PYTHON_PROTO_POLLUTION` env var flag. Python `dict.update` / `__dict__.update` proto-pollution gates are opt-in: bare `update` overlaps too broadly with `Counter.update` and ordinary state-mutation patterns to ship as a default sink. When the var is set to `1` / `true` / `yes` / `on` the merged slice is leaked into a `'static` reference so the registry's lifetime invariant holds.
- New per-cap integration suites: `tests/{xpath_injection,xxe,ssti,prototype_pollution,header_injection,open_redirect,ldap_injection}_tests.rs`, plus `python_proto_pollution_tests.rs` for the env-gated Python form. Per-cap fixture trees under `tests/fixtures/<class>/<lang>/` cover safe, unsafe, and irrelevant-baseline shapes for every supported language.
- FastAPI cross-file `include_router` dependency tracking. New `auth_analysis/router_facts.rs` captures per-file router declarations (`<router> = X(deps=[…])`) and `<parent>.include_router(<child_module>.<child_var>)` edges in pass 1, persists them into `GlobalSummaries::router_facts_by_module`, and resolves them into the active file's `AuthorizationModel::cross_file_router_deps` at pass 2 entry. Transitive lifts (`grandparent → parent → child`) handled by iterative index walk. Module identity is the file basename without `.py` (approximate, but sufficient for airflow-style `task_instances.router` naming). Closes the airflow execution-API shape where a child router lives in `routes/task_instances.py` and its auth is declared on the parent in `routes/__init__.py`.
- FastAPI router-level `dependencies=[...]` propagation. Module-level `router = APIRouter(dependencies=[Security(...)])` declarations are pre-walked once per file, then merged onto every `@<router>.<verb>(...)` route attached in the same file. Closes airflow's execution-API routes that re-use a single `ti_id_router` declared once at module scope.
- FastAPI `Security(callable, scopes=[...])` recognised distinctly from `Depends(callable)`. Scoped Security promotes the synthetic `AuthCheck` to `AuthCheckKind::Other` (route-level scope-checked authorization), not just Login. New scope-tracking boolean threaded through `expand_decorator_calls` and `extract_fastapi_dependencies`.
@ -47,6 +90,15 @@ A round of cross-file FastAPI auth, two new sink/validator classes, a ~957-FP Go
### Fixed (false positives)
- `Object.create(null)` receivers no longer fire prototype-pollution at the synthetic `__index_set__` sink. Suppression is flow-sensitive via `TypeKind::NullPrototypeObject` so a phi join that only sometimes resolves to a null-proto receiver still fires on the unsafe path.
- `cfg-unguarded-sink` over-fires on JS/TS object-literal property writes guarded by an explicit `__proto__` / `constructor` / `prototype` reject `if` (early `return` / `throw` / `break`) or by an allowlist `if` whose true arm contains the assignment. Resolved at the CFG layer before the SSA sink scan.
- Spring MVC `return "redirect:" + url` flagged generic `taint-unsanitised-flow` even when the redirect destination was the load-bearing taint. Now routed through the synthetic `__spring_redirect__` sink so the finding emerges as `taint-open-redirect`.
- `$smarty->fetch(...)` / `$twig->createTemplate(...)` no longer drop their SSTI gate match on idiomatic PHP receiver shapes. Receiver text strip in `helpers::root_receiver_text` rebuilds the chain text with `.` separators.
- `setValue(target, req.body, ...)` and similar wrappers no longer gate-match on the rewritten Source `req.body` text. Gate matcher now reads the call's `function` / `method` / `name` field when a Source label override has clobbered the call text.
- Nokogiri / lxml / fast-xml-parser parser bodies hardened with `setFeature` / `processEntities: false` / `XMLParser(resolve_entities=False)` no longer fire `taint-xxe`. Suppression runs through the new `xml_parser_config` sidecar.
- `XPath` instances bound to `setXPathVariableResolver(...)` no longer fire `taint-xpath-injection` on subsequent `xpath.evaluate(expr, ...)` sinks. Suppression runs through the new `xpath_config` sidecar.
- Inline `if (!url.startsWith("/")) reject` and `if (new URL(url).host !== ALLOWED) reject` open-redirect sanitisers now narrow the `Cap::OPEN_REDIRECT` bit on the validated branch instead of falling through to the generic `Comparison` predicate. Cap-aware: other taint downstream still fires on its own caps.
- Rust `Redirect::to` no longer fires `taint-ssrf` for what is structurally an open redirect. Retagged to `Cap::OPEN_REDIRECT` so the report classifies the issue under the correct cap.
- ~957 gitea backend DAO `go.auth.missing_ownership_check` findings (id-scalar precision pass, see Added).
- 169 of 216 openmrs `cfg-unguarded-sink` findings (JpaCriteriaQuery type, see Added). Equivalent reductions on xwiki / keycloak Hibernate DAO clusters.
- joomla and drupal `php.deser.unserialize` flagged inside `Serializable::unserialize($input)` magic-method bodies (passthrough recognition, see Added).
@ -66,6 +118,8 @@ A round of cross-file FastAPI auth, two new sink/validator classes, a ~957-FP Go
- New `cfg/cfg_tests.rs` covers ternary-branch CFG lowering shapes.
- New `summary/tests.rs` covers cross-file `include_router` summary persistence and resolution.
- Refactor passes across `auth_analysis`, `ssa/const_prop`, `ssa/type_facts`, `summary`, and the per-framework auth extractors (cleaner conditional checks, simpler function signatures, deduplicated assertions). No behaviour change.
- `parse_cap` and `CapName::FromStr` accept the new short names (`ldap_injection` / `ldapi`, `xpath_injection` / `xpathi`, `header_injection` / `crlf` / `response_splitting`, `open_redirect` / `redirect`, `ssti` / `template_injection`, `xxe`, `prototype_pollution` / `proto_pollution`, plus the existing `data_exfil` alias). The `nyx config add-rule --cap` flag and `[analysis.languages.*.rules]` entries take any of these.
- Frontend `RuleListItem` carries the new `is_class` flag so the dashboard's Rules page can group cap-class entries separately. `RuleDetailView` adds the same field.
## [0.6.1] - 2026-05-03

58
Cargo.lock generated
View file

@ -126,9 +126,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.41"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
dependencies = [
"compression-codecs",
"compression-core",
@ -257,9 +257,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.60"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"shlex",
@ -378,9 +378,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compression-codecs"
version = "0.4.37"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"compression-core",
"flate2",
@ -389,9 +389,9 @@ dependencies = [
[[package]]
name = "compression-core"
version = "0.4.31"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "console"
@ -977,10 +977,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.95"
version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@ -999,9 +1001,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.185"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
@ -1748,9 +1750,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
@ -1919,9 +1921,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.52.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
dependencies = [
"libc",
"mio",
@ -2025,9 +2027,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.8"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b"
dependencies = [
"async-compression",
"bitflags",
@ -2351,9 +2353,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.118"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
dependencies = [
"cfg-if",
"once_cell",
@ -2364,9 +2366,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.118"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -2374,9 +2376,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.118"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
dependencies = [
"bumpalo",
"proc-macro2",
@ -2387,9 +2389,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.118"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
dependencies = [
"unicode-ident",
]
@ -2430,9 +2432,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.95"
version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
dependencies = [
"js-sys",
"wasm-bindgen",

View file

@ -52,7 +52,7 @@ Everything stays on your machine: loopback-only bind, host-header enforcement, C
The same engine runs headless for CI pipelines. SARIF output uploads directly to GitHub Code Scanning.
<p align="center"><img src="assets/screenshots/cli-scan.png" alt="nyx scan console output: HIGH taint findings across a JS and Python file with source → sink arrows" width="820"/></p>
<p align="center"><img src="assets/screenshots/cli-scan.gif" alt="nyx scan console output: HIGH taint findings across a JS and Python file with source → sink arrows" width="820"/></p>
```bash
# Fail the job on medium or higher, emit SARIF
@ -186,7 +186,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: [Detectors](https://elicpeter.github.io/nyx/detectors.html).
Detector families: taint (cross-file source→sink, with cap-specific rule classes for SQLi, XSS, command/code exec, deserialization, SSRF, path traversal, format string, crypto, LDAP injection, XPath injection, HTTP header / response splitting, open redirect, server-side template injection, XXE, prototype pollution, data exfiltration, and the auth fold-in), CFG structural (auth gaps, unguarded sinks, resource leaks), state model (use-after-close, double-close, must-leak, unauthed-access), AST patterns (tree-sitter structural match). Full detector docs: [Detectors](https://elicpeter.github.io/nyx/detectors.html).
---
@ -211,7 +211,7 @@ kind = "sanitizer"
cap = "html_escape"
```
Or add rules interactively: `nyx config add-rule --lang javascript --matcher escapeHtml --kind sanitizer --cap html_escape`. Caps: `env_var`, `html_escape`, `shell_escape`, `url_encode`, `json_parse`, `file_io`, `fmt_string`, `sql_query`, `deserialize`, `ssrf`, `data_exfil`, `code_exec`, `crypto`, `unauthorized_id`, `all`. Full schema: [Configuration](https://elicpeter.github.io/nyx/configuration.html).
Or add rules interactively: `nyx config add-rule --lang javascript --matcher escapeHtml --kind sanitizer --cap html_escape`. Caps: `env_var`, `html_escape`, `shell_escape`, `url_encode`, `json_parse`, `file_io`, `fmt_string`, `sql_query`, `deserialize`, `ssrf`, `data_exfil`, `code_exec`, `crypto`, `unauthorized_id`, `ldap_injection`, `xpath_injection`, `header_injection`, `open_redirect`, `ssti`, `xxe`, `prototype_pollution`, `all`. Full schema: [Configuration](https://elicpeter.github.io/nyx/configuration.html). Run `nyx rules list` to browse the registry from the terminal.
---

View file

@ -44,8 +44,8 @@
<h2>Overview of licenses:</h2>
<ul class="licenses-overview">
<li><a href="#Apache-2.0">Apache License 2.0</a> (160)</li>
<li><a href="#MIT">MIT License</a> (71)</li>
<li><a href="#Apache-2.0">Apache License 2.0</a> (156)</li>
<li><a href="#MIT">MIT License</a> (70)</li>
<li><a href="#Zlib">zlib License</a> (2)</li>
<li><a href="#BSD-2-Clause">BSD 2-Clause &quot;Simplified&quot; License</a> (1)</li>
<li><a href="#BSD-3-Clause">BSD 3-Clause &quot;New&quot; or &quot;Revised&quot; License</a> (1)</li>
@ -1549,7 +1549,6 @@
<li><a href=" https://github.com/clap-rs/clap ">clap_derive 4.6.1</a></li>
<li><a href=" https://github.com/clap-rs/clap ">clap_lex 1.1.0</a></li>
<li><a href=" https://github.com/rust-cli/anstyle.git ">colorchoice 1.0.5</a></li>
<li><a href=" https://github.com/srijs/rust-crc32fast ">crc32fast 1.5.0</a></li>
<li><a href=" https://github.com/sfackler/rust-fallible-iterator ">fallible-iterator 0.3.0</a></li>
<li><a href=" https://github.com/sfackler/fallible-streaming-iterator ">fallible-streaming-iterator 0.1.9</a></li>
<li><a href=" https://github.com/polyfill-rs/is_terminal_polyfill ">is_terminal_polyfill 1.70.2</a></li>
@ -2616,13 +2615,16 @@ limitations under the License.</pre>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/bluss/arrayvec ">arrayvec 0.7.6</a></li>
<li><a href=" https://github.com/Nullus157/async-compression ">async-compression 0.4.42</a></li>
<li><a href=" https://github.com/smol-rs/atomic-waker ">atomic-waker 1.1.2</a></li>
<li><a href=" https://github.com/cuviper/autocfg ">autocfg 1.5.0</a></li>
<li><a href=" https://github.com/bitflags/bitflags ">bitflags 2.11.1</a></li>
<li><a href=" https://github.com/BurntSushi/bstr ">bstr 1.12.1</a></li>
<li><a href=" https://github.com/japaric/cast.rs ">cast 0.3.0</a></li>
<li><a href=" https://github.com/rust-lang/cc-rs ">cc 1.2.60</a></li>
<li><a href=" https://github.com/rust-lang/cc-rs ">cc 1.2.61</a></li>
<li><a href=" https://github.com/rust-lang/cfg-if ">cfg-if 1.0.4</a></li>
<li><a href=" https://github.com/Nullus157/async-compression ">compression-codecs 0.4.38</a></li>
<li><a href=" https://github.com/Nullus157/async-compression ">compression-core 0.4.32</a></li>
<li><a href=" https://github.com/servo/core-foundation-rs ">core-foundation-sys 0.8.7</a></li>
<li><a href=" https://github.com/criterion-rs/criterion.rs ">criterion-plot 0.8.2</a></li>
<li><a href=" https://github.com/criterion-rs/criterion.rs ">criterion 0.8.2</a></li>
@ -2636,7 +2638,6 @@ limitations under the License.</pre>
<li><a href=" https://github.com/smol-rs/fastrand ">fastrand 2.4.1</a></li>
<li><a href=" https://github.com/rust-lang/cc-rs ">find-msvc-tools 0.1.9</a></li>
<li><a href=" https://github.com/petgraph/fixedbitset ">fixedbitset 0.5.7</a></li>
<li><a href=" https://github.com/rust-lang/flate2-rs ">flate2 1.1.9</a></li>
<li><a href=" https://github.com/servo/rust-url ">form_urlencoded 1.2.2</a></li>
<li><a href=" https://github.com/rust-lang/glob ">glob 0.3.3</a></li>
<li><a href=" https://github.com/rust-lang/hashbrown ">hashbrown 0.14.5</a></li>
@ -3689,215 +3690,6 @@ APPENDIX: How to apply the Apache License to your work.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
</pre>
</li>
<li class="license">
<h3 id="Apache-2.0">Apache License 2.0</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/oyvindln/adler2 ">adler2 2.0.1</a></li>
</ul>
<pre class="license-text"> Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/LICENSE-2.0
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
&quot;License&quot; shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
&quot;Licensor&quot; shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
&quot;Legal Entity&quot; shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
&quot;control&quot; means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
&quot;You&quot; (or &quot;Your&quot;) shall mean an individual or Legal Entity
exercising permissions granted by this License.
&quot;Source&quot; form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
&quot;Object&quot; form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
&quot;Work&quot; shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
&quot;Derivative Works&quot; shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
&quot;Contribution&quot; shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, &quot;submitted&quot;
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as &quot;Not a Contribution.&quot;
&quot;Contributor&quot; shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a &quot;NOTICE&quot; text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets &quot;[]&quot;
replaced with your own identifying information. (Don&#x27;t include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same &quot;printed page&quot; as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
@ -4335,17 +4127,13 @@ limitations under the License.
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/zrzka/anes-rs ">anes 0.1.6</a></li>
<li><a href=" https://github.com/Nullus157/async-compression ">async-compression 0.4.41</a></li>
<li><a href=" https://github.com/BLAKE3-team/BLAKE3 ">blake3 1.8.5</a></li>
<li><a href=" https://github.com/Nullus157/async-compression ">compression-codecs 0.4.37</a></li>
<li><a href=" https://github.com/Nullus157/async-compression ">compression-core 0.4.31</a></li>
<li><a href=" https://github.com/cesarb/constant_time_eq ">constant_time_eq 0.4.2</a></li>
<li><a href=" https://github.com/soc/directories-rs ">directories 6.0.0</a></li>
<li><a href=" https://github.com/dirs-dev/dirs-sys-rs ">dirs-sys 0.5.0</a></li>
<li><a href=" https://github.com/VoidStarKat/half-rs ">half 2.7.1</a></li>
<li><a href=" https://github.com/dtolnay/itoa ">itoa 1.0.18</a></li>
<li><a href=" https://github.com/rust-lang/libc ">libc 0.2.185</a></li>
<li><a href=" https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide ">miniz_oxide 0.8.9</a></li>
<li><a href=" https://github.com/rust-lang/libc ">libc 0.2.186</a></li>
<li><a href=" https://github.com/jhpratt/num-conv ">num-conv 0.2.1</a></li>
<li><a href=" https://github.com/taiki-e/pin-project-lite ">pin-project-lite 0.2.17</a></li>
<li><a href=" https://github.com/taiki-e/portable-atomic ">portable-atomic 1.13.1</a></li>
@ -4361,7 +4149,7 @@ limitations under the License.
<li><a href=" https://github.com/dtolnay/path-to-error ">serde_path_to_error 0.1.20</a></li>
<li><a href=" https://github.com/nox/serde_urlencoded ">serde_urlencoded 0.7.1</a></li>
<li><a href=" https://github.com/comex/rust-shlex ">shlex 1.3.0</a></li>
<li><a href=" https://github.com/jedisct1/rust-siphash ">siphasher 1.0.2</a></li>
<li><a href=" https://github.com/jedisct1/rust-siphash ">siphasher 1.0.3</a></li>
<li><a href=" https://github.com/dtolnay/syn ">syn 2.0.117</a></li>
<li><a href=" https://github.com/Actyx/sync_wrapper ">sync_wrapper 1.0.2</a></li>
<li><a href=" https://github.com/dtolnay/thiserror ">thiserror-impl 2.0.18</a></li>
@ -5338,7 +5126,7 @@ DEALINGS IN THE SOFTWARE.
<h3 id="MIT">MIT License</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/tower-rs/tower-http ">tower-http 0.6.8</a></li>
<li><a href=" https://github.com/tower-rs/tower-http ">tower-http 0.6.9</a></li>
</ul>
<pre class="license-text">Copyright (c) 2019-2021 Tower Contributors
@ -5736,7 +5524,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE.
<ul class="license-used-by">
<li><a href=" https://github.com/tokio-rs/tokio ">tokio-stream 0.1.18</a></li>
<li><a href=" https://github.com/tokio-rs/tokio ">tokio-util 0.7.18</a></li>
<li><a href=" https://github.com/tokio-rs/tokio ">tokio 1.52.1</a></li>
<li><a href=" https://github.com/tokio-rs/tokio ">tokio 1.52.2</a></li>
</ul>
<pre class="license-text">MIT License
@ -5752,35 +5540,6 @@ 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 &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</pre>
</li>
<li class="license">
<h3 id="MIT">MIT License</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/mcountryman/simd-adler32 ">simd-adler32 0.3.9</a></li>
</ul>
<pre class="license-text">MIT License
Copyright (c) [2021] [Marvin Countryman]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the &quot;Software&quot;), 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 &quot;AS IS&quot;, 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 432 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

View file

@ -6,5 +6,5 @@
font-weight="700"
font-size="100"
letter-spacing="-1"
fill="#5856d6">nyx</text>
fill="#72f3d7">nyx</text>
</svg>

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 392 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 24 MiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Before After
Before After

View file

@ -110,7 +110,7 @@ nyx scan . --severity ">=MEDIUM" --min-confidence medium
Auth findings render alongside taint findings in the [browser UI](serve.md). The flow visualiser shows the sink call, the actor reference (when one was found), and any helper-summary path the engine traversed; the How to fix panel mirrors the rule's recommendation.
<p align="center"><img src="../assets/screenshots/docs/serve-finding-detail.png" alt="Nyx finding detail: numbered source → call → sink walk with a How to fix panel and an inline evidence object" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-finding-detail.png" alt="Nyx finding detail: numbered source → call → sink walk with a How to fix panel and an inline evidence object" width="900"/></p>
## Benchmark corpus

View file

@ -95,11 +95,11 @@ nyx scan [PATH] [OPTIONS]
`--fail-on` returns a non-zero exit code when the threshold trips, so CI jobs fail without further wiring:
<p align="center"><img src="../assets/screenshots/docs/cli-failon.png" alt="nyx scan with --fail-on HIGH against a small fixture: three HIGH taint findings printed, followed by exit=1 from the shell" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/cli-failon.png" alt="nyx scan with --fail-on HIGH against a small fixture: three HIGH taint findings printed, followed by exit=1 from the shell" width="900"/></p>
Quality-category and rollup-prone Low findings are filtered down by default. The footer tells you exactly what got dropped and which knob to turn:
<p align="center"><img src="../assets/screenshots/docs/cli-rollup-tail.png" alt="nyx scan tail: warning '*' generated 57 issues; Suppressed 92 LOW/Quality findings; Active filters max_low=20, max_low_per_file=1, max_low_per_rule=10; Use --include-quality, --max-low, or --all to adjust" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/cli-rollup-tail.png" alt="nyx scan tail: warning '*' generated 57 issues; Suppressed 92 LOW/Quality findings; Active filters max_low=20, max_low_per_file=1, max_low_per_rule=10; Use --include-quality, --max-low, or --all to adjust" width="900"/></p>
### Analysis Engine Toggles
@ -150,7 +150,7 @@ Individual flags override the profile. For example, `--engine-profile fast --ba
nyx scan --engine-profile deep --no-smt --explain-engine
```
<p align="center"><img src="../assets/screenshots/docs/cli-explain-engine.png" alt="nyx scan --engine-profile deep --explain-engine output: resolved config showing every analysis pass, its current state, and the CLI flag/env var that controls it" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/cli-explain-engine.png" alt="nyx scan --engine-profile deep --explain-engine output: resolved config showing every analysis pass, its current state, and the CLI flag/env var that controls it" width="900"/></p>
### Examples
@ -215,7 +215,7 @@ nyx index status [PATH]
Display index statistics (file count, size, last modified) for the given path.
<p align="center"><img src="../assets/screenshots/docs/cli-idxstatus.png" alt="nyx index status output: project name, index path under the platform config dir, exists/size/modified fields" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/cli-idxstatus.png" alt="nyx index status output: project name, index path under the platform config dir, exists/size/modified fields" width="900"/></p>
---
@ -256,7 +256,7 @@ Manage configuration.
Print the effective merged configuration as TOML. Useful for sanity-checking what the scanner is actually using after `nyx.conf` and `nyx.local` merge:
<p align="center"><img src="../assets/screenshots/docs/cli-configshow.png" alt="nyx config show output: TOML dump of the merged scanner config showing [scanner] mode/min_severity/excluded_extensions/excluded_directories, [database] settings, and resolved engine toggles" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/cli-configshow.png" alt="nyx config show output: TOML dump of the merged scanner config showing [scanner] mode/min_severity/excluded_extensions/excluded_directories, [database] settings, and resolved engine toggles" width="900"/></p>
### `nyx config path`
@ -275,7 +275,7 @@ Add a custom taint rule. Written to `nyx.local`.
| `--lang` | `rust`, `javascript`, `typescript`, `python`, `go`, `java`, `c`, `cpp`, `php`, `ruby` |
| `--matcher` | Function or property name to match |
| `--kind` | `source`, `sanitizer`, `sink` |
| `--cap` | `env_var`, `html_escape`, `shell_escape`, `url_encode`, `json_parse`, `file_io`, `fmt_string`, `sql_query`, `deserialize`, `ssrf`, `code_exec`, `crypto`, `unauthorized_id`, `all` |
| `--cap` | `env_var`, `html_escape`, `shell_escape`, `url_encode`, `json_parse`, `file_io`, `fmt_string`, `sql_query`, `deserialize`, `ssrf`, `code_exec`, `crypto`, `unauthorized_id`, `data_exfil`, `ldap_injection`, `xpath_injection`, `header_injection`, `open_redirect`, `ssti`, `xxe`, `prototype_pollution`, `all` |
### `nyx config add-terminator`
@ -287,6 +287,41 @@ Add a terminator function (e.g. `process.exit`). Written to `nyx.local`.
---
## `nyx rules`
Browse the built-in rule registry from the terminal. Same dataset the dashboard's Rules page reads from: cap-class entries (one per `Cap` with a canonical rule id), per-language label rules (sink / source / sanitizer), gated sinks, and any custom rules from your config.
### `nyx rules list`
```
nyx rules list [--lang <SLUG>] [--kind <KIND>] [--class-only|--no-class] [--json]
```
| Flag | Values |
|------|--------|
| `--lang` | Language slug (`javascript`, `typescript`, `python`, `java`, `php`, `go`, `ruby`, `rust`, `c`, `cpp`). Cap-class entries (`language = "all"`) still surface alongside any language filter unless `--no-class` is set. |
| `--kind` | `class` (cap-class entry), `source`, `sink`, `sanitizer` |
| `--class-only` | Show only the cap-class registry entries, suppressing per-language label rules and gated sinks. |
| `--no-class` | Suppress cap-class registry entries, show only per-language label rules and gated sinks. Conflicts with `--class-only`. |
| `--json` | Emit JSON instead of the human-readable table. Schema matches the `/api/rules` response. |
Examples:
```bash
# Browse the seven new vulnerability classes
nyx rules list --class-only
# All Java sinks
nyx rules list --lang java --kind sink
# JSON output for scripted filtering
nyx rules list --json | jq '.[] | select(.cap == "ldap_injection")'
```
The `enabled` column reflects the `analysis.disabled_rules` overlay from your config, so a rule disabled in `nyx.local` shows up here too. Custom rules added via `nyx config add-rule` appear at the end with `is_custom: true`.
---
## Exit codes
See [output.md](output.md#exit-codes). Summary: `0` on success (including findings without `--fail-on`), `1` when `--fail-on` trips, non-zero on scan errors.

View file

@ -2,7 +2,7 @@
Nyx uses TOML configuration files. A default config is auto-generated on first run. If you'd rather edit settings and rules from the browser, the [Config page in `nyx serve`](serve.md#config) is a live editor that writes back to `nyx.local`:
<p align="center"><img src="../assets/screenshots/docs/serve-config.png" alt="Nyx config page: General settings, Triage Sync toggle, Sources panel with language/matcher/capability dropdowns and a per-language matcher table" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-config.png" alt="Nyx config page: General settings, Triage Sync toggle, Sources panel with language/matcher/capability dropdowns and a per-language matcher table" width="900"/></p>
## File Locations
@ -253,9 +253,14 @@ cap = "html_escape" # "env_var" | "html_escape" | "shell_escape" |
# "url_encode" | "json_parse" | "file_io" |
# "fmt_string" | "sql_query" | "deserialize" |
# "ssrf" | "data_exfil" | "code_exec" | "crypto" |
# "unauthorized_id" | "all"
# "unauthorized_id" | "ldap_injection" |
# "xpath_injection" | "header_injection" |
# "open_redirect" | "ssti" | "xxe" |
# "prototype_pollution" | "all"
```
Aliases accepted by `parse_cap` and `[..rules].cap`: `data_exfiltration` for `data_exfil`, `ldapi` for `ldap_injection`, `xpathi` for `xpath_injection`, `crlf` and `response_splitting` for `header_injection`, `redirect` for `open_redirect`, `template_injection` for `ssti`, `proto_pollution` for `prototype_pollution`.
---
## Example Configurations

View file

@ -13,11 +13,20 @@ The taint family is split into cap-specific rule classes when a sink callee carr
| Rule id | Cap | Surface |
|---|---|---|
| `taint-unsanitised-flow` | every cap except `data_exfil` and `unauthorized_id` | Default taint flow class |
| `taint-unsanitised-flow` | `sql_query`, `ssrf`, `code_exec`, `file_io`, `fmt_string`, `deserialize`, `crypto` | Catch-all class for the legacy caps that have not migrated to a dedicated rule id yet. |
| `taint-ldap-injection` | `ldap_injection` | Attacker-controlled data concatenated into an LDAP filter or DN without RFC 4515 escaping. Receivers typed as `LdapClient` (JNDI `DirContext`, Spring `LdapTemplate`, ldapjs `Client`, python-ldap `LDAPObject`, ldap3 `Connection`) and chained `.search` / `.searchByEntity` / `.search_s` form the sink set. |
| `taint-xpath-injection` | `xpath_injection` | Attacker-controlled string passed as the XPath expression to `xpath.evaluate` / `xpath.compile` / `document.evaluate` / `DOMXPath::query` / `etree.XPath`. Suppressed when the receiver was bound to an `XPathVariableResolver` (parameterised XPath shape). |
| `taint-header-injection` | `header_injection` | Attacker-controlled bytes landing in an HTTP response header without `\r\n` stripping (response splitting, cache poisoning). Covers `setHeader` / `res.set` / `res.append` / `headers["X-Foo"] = bar` / `Header().Set` / `add_header` / `setcookie` / `http.Header.Set`. |
| `taint-open-redirect` | `open_redirect` | Attacker-controlled URL driving a redirect / `Location` header without an allowlist or relative-URL check. Includes the Spring MVC `return "redirect:" + url` view-name shape via the `__spring_redirect__` synthetic sink. Suppressed by `RelativeUrlValidated` (`startsWith("/")` family) and `HostAllowlistValidated` (`new URL(x).host === ALLOWED`, `urlparse(x).netloc == ...`) inline predicates. |
| `taint-template-injection` | `ssti` | Attacker controls the *template source string* fed to a server-side renderer (Jinja2 / Mako / FreeMarker / Twig / Handlebars / EJS / Mustache / ERB / `text/template` / `html/template` / Smarty / Blade `Template(...)` / `compile(...)`), distinct from rendering a trusted template with tainted variables. |
| `taint-xxe` | `xxe` | Attacker-controlled XML reaching a parser that resolves external entities. Covers JAXP `DocumentBuilder.parse` / `SAXParser.parse` / `XMLReader.parse`, lxml `etree.parse`, Nokogiri, fast-xml-parser, xml2js, libxml2 `xmlReadFile` / `xmlReadMemory`. Suppressed when the receiver carries a hardening fact in `xml_parser_config` (`secure_processing`, `disallow_doctype`, `processEntities: false`, `LIBXML_NOENT` not set). |
| `taint-prototype-pollution` | `prototype_pollution` | Attacker-controlled key reaching an object property assignment that can mutate `Object.prototype`. JS/TS only. Covers `obj[tainted] = v` (synthetic `__index_set__` sink), library-mediated deep-merge / set helpers (`_.merge`, `_.set`, `dotProp.set`, `objectPath.set`, `setValue`), and jQuery's `extend(true, target, src)` deep-merge form via the `LiteralOnly` activation gate. Suppressed by constant-key fold (`__proto__` / `constructor` / `prototype` filtering), reject / allowlist guards on the key, and `Object.create(null)` receivers (flow-sensitive `NullPrototypeObject` type). Python equivalent (`dict.update`) is opt-in via `NYX_PYTHON_PROTO_POLLUTION=1`. |
| `taint-data-exfiltration` | `data_exfil` | Sensitive data flowing into the payload of an outbound network request (body / headers / json on `fetch`, body on `XMLHttpRequest.send`). Distinct from SSRF: the destination is fixed but attacker-influenced bytes leave the process. |
| `rs.auth.missing_ownership_check.taint` | `unauthorized_id` | Rust auth subsystem fold-in; see [auth.md](auth.md). |
A single call site can fire several of these at once when it carries multiple gates — `fetch(taintedUrl, {body: tainted})` produces both an SSRF finding (URL flow) and a `taint-data-exfiltration` finding (body flow), each with its own cap mask rather than a conflated union.
A single call site can fire several of these at once when it carries multiple gates. `fetch(taintedUrl, {body: tainted})` produces both an SSRF finding (URL flow) and a `taint-data-exfiltration` finding (body flow), each with its own cap mask rather than a conflated union.
Each cap-class entry is registered in `CAP_RULE_REGISTRY` (`src/labels/mod.rs`) with its title, severity, OWASP 2021 code, and description. Browse the registry from the CLI with `nyx rules list --class-only`, or `nyx rules list --kind class --json` for machine output.
For Rust auth-specific rules (`rs.auth.*`), see [auth.md](auth.md).

View file

@ -92,7 +92,7 @@ AST-only mode gives you structural pattern matches without taint.
In the browser UI, taint findings render as a numbered flow walk so you can see each hop the engine took:
<p align="center"><img src="../../assets/screenshots/docs/serve-finding-detail.png" alt="Nyx finding detail: HIGH taint-unsanitised-flow with numbered source → call → sink steps and How to fix guidance" width="900"/></p>
<p align="center"><img src="../assets/screenshots/docs/serve-finding-detail.png" alt="Nyx finding detail: HIGH taint-unsanitised-flow with numbered source → call → sink steps and How to fix guidance" width="900"/></p>
## Example
@ -135,10 +135,17 @@ Sources, sanitizers, and sinks are linked by named capabilities. A sanitizer onl
| `sql_query` | | parameterized query binders | `cursor.execute`, `db.query` with concatenation |
| `deserialize` | | | `pickle.loads`, `yaml.load`, `Marshal.load` |
| `ssrf` | | URL-prefix locks | `requests.get`, `fetch` URL arg, outbound HTTP destination |
| `data_exfil` | cookies, headers, env, db rows, file reads (Sensitive-tier sources only) | | `fetch` body / headers / json, `XMLHttpRequest.send` body |
| `code_exec` | | | `eval`, `exec`, `Function` |
| `crypto` | | | weak-algorithm constructors |
| `unauthorized_id` | request-bound scoped IDs (Rust auth analysis) | ownership check | row-level write |
| `ldap_injection` | | `ldap-escape` filter / dn helpers, project-local `escapeLdapFilter` | `DirContext.search`, `LdapClient.search`, `ldap_search`, `Net::LDAP#search`, `ldap_search_ext_s` |
| `xpath_injection` | | bound `XPathVariableResolver`, `escapeXpath` / `xpathEscape` helpers | `XPath.evaluate`, `DOMXPath::query`, `document.evaluate`, `xpath.select`, `etree.XPath` |
| `header_injection` | | `stripCRLF` / `escapeHeader` / `sanitizeHeader` | `setHeader`, `res.set`, `res.append`, `headers["X-Foo"] = bar`, `Header().Set`, `header()`, `setcookie` |
| `open_redirect` | | leading-slash check (`startsWith("/")`), URL-parse + host allowlist (`new URL(x).host === ALLOWED`) | `Redirect::to`, Spring `redirect:` view name, `flask.redirect`, `http.Redirect`, `redirect_to` |
| `ssti` | | | template constructors fed by tainted source: `Jinja2 Template(...)`, `freemarker.Template`, `Twig::createTemplate`, Handlebars `compile`, `ERB.new`, Mako `Template(...)` |
| `xxe` | | hardened parser config (`secure_processing`, `disallow-doctype-decl`, `processEntities: false`, `LIBXML_NOENT` not set) | `DocumentBuilder.parse`, `SAXParser.parse`, `xml2js`, `fast-xml-parser`, `lxml.etree.parse`, `xmlReadFile` |
| `prototype_pollution` | | constant-key fold, reject / allowlist guards on the key, `Object.create(null)` receivers | `obj[tainted] = v` synthetic `__index_set__`, `_.merge`, `_.set`, `dotProp.set`, `objectPath.set`, jQuery `extend(true, ...)` |
| `data_exfil` | cookies, headers, env, db rows, file reads (Sensitive-tier sources only) | | `fetch` body / headers / json, `XMLHttpRequest.send` body |
| `all` | Sources typically use `all` so they match any sink | | |
Sources typically use `cap = "all"` so they match every sink. Sinks declare the specific cap they need. Sanitizers only clear the cap they name.

View file

@ -10,7 +10,7 @@ First run builds a SQLite index under `.nyx/`; later runs skip files whose conte
## What a finding looks like
<p align="center"><img src="../assets/screenshots/cli-scan.png" alt="nyx scan output: HIGH taint flows from req.params.user, req.query.url, and req.query.path into exec/fetch/fs.readFileSync, framed by the brand purple gradient" width="900"/></p>
<p align="center"><img src="assets/screenshots/cli-scan.png" alt="nyx scan output: HIGH taint flows from req.params.user, req.query.url, and req.query.path into exec/fetch/fs.readFileSync, framed by the brand mint-cyan gradient" width="900"/></p>
The same scan in console form:

View file

@ -6,7 +6,7 @@ Every finding Nyx emits has a rule ID. This page enumerates the IDs that ship wi
If you'd rather browse rules interactively, [`nyx serve`](serve.md) ships a Rules page that lists every loaded matcher with its language, kind, and capability:
<p align="center"><img src="../assets/screenshots/docs/serve-rules.png" alt="Nyx Rules page: filterable list of 218 rules with language, kind (SOURCE/SANITIZER/SINK), capability, and finding count columns" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-rules.png" alt="Nyx Rules page: filterable list of 218 rules with language, kind (SOURCE/SANITIZER/SINK), capability, and finding count columns" width="900"/></p>
## ID format
@ -24,13 +24,22 @@ Language prefixes: `rs`, `c`, `cpp`, `go`, `java`, `js`, `ts`, `py`, `php`, `rb`
### Taint
One rule covers every source-to-sink flow. The parenthetical identifies the source location.
The taint family is split into cap-specific rule classes. The `taint-unsanitised-flow` id is the catch-all for the legacy caps that have not migrated to a dedicated rule id yet (`sql_query`, `ssrf`, `code_exec`, `file_io`, `fmt_string`, `deserialize`, `crypto`). The seven new vulnerability classes plus auth and data-exfil emerge under their own rule id. The parenthetical identifies the source location.
| Rule ID | Severity |
|---|---|
| `taint-unsanitised-flow (source L:C)` | Varies by source kind and sink capability |
| Rule ID | Cap | Severity |
|---|---|---|
| `taint-unsanitised-flow (source L:C)` | `sql_query` / `ssrf` / `code_exec` / `file_io` / `fmt_string` / `deserialize` / `crypto` | Varies |
| `taint-ldap-injection` | `ldap_injection` | High |
| `taint-xpath-injection` | `xpath_injection` | High |
| `taint-header-injection` | `header_injection` | High |
| `taint-open-redirect` | `open_redirect` | Medium |
| `taint-template-injection` | `ssti` | High |
| `taint-xxe` | `xxe` | High |
| `taint-prototype-pollution` | `prototype_pollution` | High |
| `taint-data-exfiltration` | `data_exfil` | High / Medium |
| `rs.auth.missing_ownership_check.taint` | `unauthorized_id` | High |
The matcher sets (sources, sanitizers, sinks, gated sinks) live per-language in `src/labels/<lang>.rs`. [Language maturity](language-maturity.md) gives per-language counts and what's covered.
Each cap-class entry is registered in `CAP_RULE_REGISTRY` (`src/labels/mod.rs`). Browse the registry from the CLI with `nyx rules list --class-only`, or via the dashboard's Rules page. The matcher sets (sources, sanitizers, sinks, gated sinks) live per-language in `src/labels/<lang>.rs`. [Language maturity](language-maturity.md) gives per-language counts and what's covered.
### CFG structural
@ -257,6 +266,8 @@ The tables below are generated from `src/patterns/<lang>.rs` by [`tools/docgen`]
`nyx config add-rule --cap <name>` and `[analysis.languages.*.rules]` in config accept:
`env_var`, `html_escape`, `shell_escape`, `url_encode`, `json_parse`, `file_io`, `fmt_string`, `sql_query`, `deserialize`, `ssrf`, `code_exec`, `crypto`, `unauthorized_id`, `all`
`env_var`, `html_escape`, `shell_escape`, `url_encode`, `json_parse`, `file_io`, `fmt_string`, `sql_query`, `deserialize`, `ssrf`, `code_exec`, `crypto`, `unauthorized_id`, `data_exfil`, `ldap_injection`, `xpath_injection`, `header_injection`, `open_redirect`, `ssti`, `xxe`, `prototype_pollution`, `all`
Source for both the enum and the `to_cap` mapping: [`src/labels/mod.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/mod.rs) (`Cap`) and [`src/utils/config.rs`](https://github.com/elicpeter/nyx/blob/master/src/utils/config.rs) (`CapName`).
Aliases: `data_exfiltration` for `data_exfil`, `ldapi` for `ldap_injection`, `xpathi` for `xpath_injection`, `crlf` and `response_splitting` for `header_injection`, `redirect` for `open_redirect`, `template_injection` for `ssti`, `proto_pollution` for `prototype_pollution`.
Source for both the enum and the `to_cap` mapping: [`src/labels/mod.rs`](https://github.com/elicpeter/nyx/blob/master/src/labels/mod.rs) (`Cap` and `CAP_RULE_REGISTRY`) and [`src/utils/config.rs`](https://github.com/elicpeter/nyx/blob/master/src/utils/config.rs) (`CapName`).

View file

@ -11,7 +11,7 @@ nyx serve --no-browser # don't auto-open
Persistent settings live under `[server]` in `nyx.conf` / `nyx.local`.
<p align="center"><img src="../assets/screenshots/docs/serve-overview.png" alt="Nyx UI overview: total findings, severity breakdown, language and category distribution, top affected files" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-overview.png" alt="Nyx UI overview: total findings, severity breakdown, language and category distribution, top affected files" width="900"/></p>
## What it serves, and what it doesn't
@ -88,11 +88,11 @@ Ceilings are calibrated for the current scanner false-positive rates. As symex c
The findings list is filterable by severity, confidence, category, language, rule ID, and triage state.
<p align="center"><img src="../assets/screenshots/docs/serve-findings-list.png" alt="Nyx findings list: 13 findings filtered by severity/confidence/rule, with status badges, file paths, and language tags" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-findings-list.png" alt="Nyx findings list: 13 findings filtered by severity/confidence/rule, with status badges, file paths, and language tags" width="900"/></p>
Clicking through opens the **flow visualiser**: a numbered walk from source to sink with the snippet at each step, cross-file markers when the path leaves the current file, the rule's "How to fix" guidance, and the engine's evidence object inline.
<p align="center"><img src="../assets/screenshots/docs/serve-finding-detail.png" alt="Nyx finding detail: HIGH taint-unsanitised-flow showing source → call → sink steps, How to fix guidance, and evidence panel" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-finding-detail.png" alt="Nyx finding detail: HIGH taint-unsanitised-flow showing source → call → sink steps, How to fix guidance, and evidence panel" width="900"/></p>
Engine notes call out when precision was bounded for that finding (`OriginsTruncated`, `PointsToTruncated`, `PathWidened`, `ForwardBailed`, etc.). Anything tagged `under-report` means the emitted flow is real and the result set is a lower bound; `over-report` means widening or bail. `--require-converged` in the CLI drops the over-report ones for strict gates.
@ -100,7 +100,7 @@ Engine notes call out when precision was bounded for that finding (`OriginsTrunc
Each finding carries a triage state: `open`, `investigating`, `false_positive`, `accepted_risk`, `suppressed`, or `fixed`. The triage page bulk-updates them and shows the audit trail.
<p align="center"><img src="../assets/screenshots/docs/serve-triage.png" alt="Nyx triage page: 13 findings need attention, severity breakdown, Findings/Suppression rules/Audit log tabs, rule chips, Investigate buttons" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-triage.png" alt="Nyx triage page: 13 findings need attention, severity breakdown, Findings/Suppression rules/Audit log tabs, rule chips, Investigate buttons" width="900"/></p>
State writes are persisted to SQLite immediately, and (when `[server].triage_sync = true`, default on) mirrored to `.nyx/triage.json` in the project root. Commit that file:
@ -114,7 +114,7 @@ It carries decisions across machines so a teammate's local scan reflects yours.
A file tree with per-file finding counts, syntax-highlighted source, and a right rail with the file's symbols and findings. Useful for "what's wrong with this module" rather than "what's wrong with this finding".
<p align="center"><img src="../assets/screenshots/docs/serve-explorer.png" alt="Nyx explorer: file tree with per-file finding counts, syntax-highlighted Python source with red sink marker on the os.system line, file-summary right rail with findings" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-explorer.png" alt="Nyx explorer: file tree with per-file finding counts, syntax-highlighted Python source with red sink marker on the os.system line, file-summary right rail with findings" width="900"/></p>
The path query string preselects a file: `/explorer?file=src/handler.rs`.
@ -122,11 +122,11 @@ The path query string preselects a file: `/explorer?file=src/handler.rs`.
Past runs are persisted when `[runs].persist = true` (off by default to avoid disk growth on heavy users). When persistence is on, `/scans` lists historical runs.
<p align="center"><img src="../assets/screenshots/docs/serve-scans.png" alt="Nyx scans list: completed scan run with root, duration, finding count, languages, and started timestamp" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-scans.png" alt="Nyx scans list: completed scan run with root, duration, finding count, languages, and started timestamp" width="900"/></p>
Each run drills into a detail page with files scanned, findings count, duration, languages, and a per-pass timing breakdown.
<p align="center"><img src="../assets/screenshots/docs/serve-scan-detail.png" alt="Nyx scan detail: Summary tab with files scanned, findings, duration, languages; Details panel with Scan ID, Root, Engine version, started/finished timestamps; Timing breakdown bar showing Walk/Pass 1/Call Graph/Pass 2/Post" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-scan-detail.png" alt="Nyx scan detail: Summary tab with files scanned, findings, duration, languages; Details panel with Scan ID, Root, Engine version, started/finished timestamps; Timing breakdown bar showing Walk/Pass 1/Call Graph/Pass 2/Post" width="900"/></p>
Pick two scans to diff and see what got introduced, fixed, or rediscovered between runs. The retention cap is `[runs].max_runs` (default 100). Each run can also optionally save its log and stdout (`save_logs`, `save_stdout`); both are off by default. Code snippets are saved (`save_code_snippets = true`); turn off if storage is tight.
@ -134,7 +134,7 @@ Pick two scans to diff and see what got introduced, fixed, or rediscovered betwe
Every rule the engine knows about, built-in plus user-added. Each row shows the matchers, kind (source / sanitiser / sink), capability, language, and how many findings it produced in the latest scan. Filter by language, by kind, or by free text.
<p align="center"><img src="../assets/screenshots/docs/serve-rules.png" alt="Nyx rules page: 218 rules with language/kind dropdowns and a matcher search; rows showing rule title, language, kind (SOURCE/SANITIZER/SINK), cap, and finding count" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-rules.png" alt="Nyx rules page: 218 rules with language/kind dropdowns and a matcher search; rows showing rule title, language, kind (SOURCE/SANITIZER/SINK), cap, and finding count" width="900"/></p>
User-added rules can be deleted from this page; built-ins are immutable. Built-ins live in `src/labels/<lang>.rs` and `src/patterns/<lang>.rs`; user-added entries write to `nyx.local`.
@ -142,7 +142,7 @@ User-added rules can be deleted from this page; built-ins are immutable. Built-i
A live config editor. Reads the merged config (`nyx.conf` + `nyx.local`), lets you flip switches and add custom source / sanitizer / sink rules, and writes back to `nyx.local`. Changes apply to the next scan; the running server uses its initial config snapshot.
<p align="center"><img src="../assets/screenshots/docs/serve-config.png" alt="Nyx config page: General settings (analysis mode, max file size, excluded extensions, attack-surface ranking), Triage Sync toggle, Sources section with language/matcher/capability dropdowns and a per-language matcher table" width="900"/></p>
<p align="center"><img src="assets/screenshots/docs/serve-config.png" alt="Nyx config page: General settings (analysis mode, max file size, excluded extensions, attack-surface ranking), Triage Sync toggle, Sources section with language/matcher/capability dropdowns and a per-language matcher table" width="900"/></p>
The custom-rule form picks a language, a matcher (function or property name), and a capability. The capability list matches the `Cap` bitflags the taint engine uses; see [rules.md](rules.md#capability-list-for-custom-rules) for what each one means.

View file

@ -3,8 +3,16 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nyx Scanner</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Nyx</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon-64.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

View file

@ -9,13 +9,13 @@
"version": "0.6.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@tanstack/react-query": "^5.100.6",
"@tanstack/react-query": "^5.100.9",
"elkjs": "^0.11.1",
"graphology": "^0.26.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"sigma": "^3.0.2"
"react-router-dom": "^7.15.0",
"sigma": "^3.0.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -26,15 +26,15 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^29.1.0",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"license-checker-rseidelsohn": "^4.4.2",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.1",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.10",
"vitest": "^4.1.5"
}
@ -113,9 +113,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
"dev": true,
"license": "MIT",
"engines": {
@ -274,9 +274,9 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1180,9 +1180,9 @@
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.100.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.6.tgz",
"integrity": "sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg==",
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
"integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==",
"license": "MIT",
"funding": {
"type": "github",
@ -1190,12 +1190,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.6.tgz",
"integrity": "sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA==",
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz",
"integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.6"
"@tanstack/query-core": "5.100.9"
},
"funding": {
"type": "github",
@ -1296,9 +1296,9 @@
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@ -1374,17 +1374,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
"integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/type-utils": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"@typescript-eslint/scope-manager": "8.59.2",
"@typescript-eslint/type-utils": "8.59.2",
"@typescript-eslint/utils": "8.59.2",
"@typescript-eslint/visitor-keys": "8.59.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@ -1397,7 +1397,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.59.1",
"@typescript-eslint/parser": "^8.59.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@ -1413,16 +1413,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz",
"integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"@typescript-eslint/scope-manager": "8.59.2",
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/typescript-estree": "8.59.2",
"@typescript-eslint/visitor-keys": "8.59.2",
"debug": "^4.4.3"
},
"engines": {
@ -1438,14 +1438,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
"integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.1",
"@typescript-eslint/types": "^8.59.1",
"@typescript-eslint/tsconfig-utils": "^8.59.2",
"@typescript-eslint/types": "^8.59.2",
"debug": "^4.4.3"
},
"engines": {
@ -1460,14 +1460,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
"integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1"
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/visitor-keys": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1478,9 +1478,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
"integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
"dev": true,
"license": "MIT",
"engines": {
@ -1495,15 +1495,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz",
"integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/typescript-estree": "8.59.2",
"@typescript-eslint/utils": "8.59.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@ -1520,9 +1520,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
"dev": true,
"license": "MIT",
"engines": {
@ -1534,16 +1534,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
"integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.59.1",
"@typescript-eslint/tsconfig-utils": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"@typescript-eslint/project-service": "8.59.2",
"@typescript-eslint/tsconfig-utils": "8.59.2",
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/visitor-keys": "8.59.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@ -1575,16 +1575,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz",
"integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1"
"@typescript-eslint/scope-manager": "8.59.2",
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/typescript-estree": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1599,13 +1599,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
"integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/types": "8.59.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@ -1922,9 +1922,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
"integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
"integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -2204,9 +2204,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.344",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
"version": "1.5.350",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.350.tgz",
"integrity": "sha512-/KWD4qK8nMqIoJh35Rpc37fiVyOe80mcUQKpfje0Dp9uot2ROuipsh+EriCdfInxjleD5v1S4OlIn41I0LXP0g==",
"dev": true,
"license": "ISC"
},
@ -2267,9 +2267,9 @@
}
},
"node_modules/eslint": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
"integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2688,9 +2688,9 @@
}
},
"node_modules/globals": {
"version": "17.5.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz",
"integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==",
"version": "17.6.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
"integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
"dev": true,
"license": "MIT",
"engines": {
@ -2850,13 +2850,13 @@
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
@ -2975,9 +2975,9 @@
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz",
"integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==",
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3016,9 +3016,9 @@
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@ -3578,9 +3578,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@ -3818,9 +3818,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@ -3942,9 +3942,9 @@
"peer": true
},
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
"integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@ -3964,12 +3964,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz",
"integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.2"
"react-router": "7.15.0"
},
"engines": {
"node": ">=20.0.0"
@ -4147,9 +4147,9 @@
"license": "ISC"
},
"node_modules/sigma": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz",
"integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==",
"license": "MIT",
"dependencies": {
"events": "^3.3.0",
@ -4408,9 +4408,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"dev": true,
"license": "MIT",
"engines": {
@ -4445,22 +4445,22 @@
}
},
"node_modules/tldts": {
"version": "7.0.29",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.29.tgz",
"integrity": "sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==",
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.29"
"tldts-core": "^7.0.30"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.29",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.29.tgz",
"integrity": "sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q==",
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
"dev": true,
"license": "MIT"
},
@ -4549,16 +4549,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz",
"integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz",
"integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.59.1",
"@typescript-eslint/parser": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/utils": "8.59.1"
"@typescript-eslint/eslint-plugin": "8.59.2",
"@typescript-eslint/parser": "8.59.2",
"@typescript-eslint/typescript-estree": "8.59.2",
"@typescript-eslint/utils": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -5016,9 +5016,9 @@
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"funding": {

View file

@ -18,13 +18,13 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@tanstack/react-query": "^5.100.6",
"@tanstack/react-query": "^5.100.9",
"elkjs": "^0.11.1",
"graphology": "^0.26.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"sigma": "^3.0.2"
"react-router-dom": "^7.15.0",
"sigma": "^3.0.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -35,15 +35,15 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^29.1.0",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"license-checker-rseidelsohn": "^4.4.2",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.1",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.10",
"vitest": "^4.1.5"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

View file

@ -5,7 +5,7 @@ let csrfTokenPromise: Promise<string> | null = null;
export class ApiError extends Error {
/**
* Stable machine-readable code (matches backend `ApiError`'s `code` field).
* Falls back to a synthetic value when the response wasn't structured
* Falls back to a synthetic value when the response was not structured,
* `network` for fetch failures, `http_<status>` for plain-text responses.
*/
public code: string;
@ -49,7 +49,7 @@ async function errorFromResponse(res: Response): Promise<ApiError> {
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.
// Plain-text body, use as-is.
return new ApiError(res.status, text);
}
}

View file

@ -355,6 +355,7 @@ export interface RuleListItem {
enabled: boolean;
is_custom: boolean;
is_gated: boolean;
is_class: boolean;
case_sensitive: boolean;
finding_count: number;
suppression_rate: number;

View file

@ -23,10 +23,10 @@ export function HorizontalBarChart({
);
}
const barH = 22;
const gap = 4;
const labelW = 110;
const valueW = 45;
const barH = 32;
const gap = 12;
const labelW = 120;
const valueW = 48;
const barAreaW = width - labelW - valueW - 16;
const totalH = items.length * (barH + gap);
const maxVal = maxValue ?? Math.max(...items.map((i) => i.value), 1);
@ -49,7 +49,7 @@ export function HorizontalBarChart({
x={labelW - 8}
y={y + barH / 2 + 4}
textAnchor="end"
fontSize={11}
fontSize={13}
fontFamily="var(--font)"
fill="var(--text-secondary)"
>
@ -68,7 +68,7 @@ export function HorizontalBarChart({
x={labelW + barAreaW + 8}
y={y + barH / 2 + 4}
textAnchor="start"
fontSize={11}
fontSize={13}
fontFamily="var(--font-mono)"
fontWeight={600}
fill="var(--text)"

View file

@ -16,7 +16,7 @@ export function LineChart({
points,
color = 'var(--accent)',
width = 400,
height = 160,
height = 240,
}: LineChartProps) {
if (!points || points.length < 2) {
return (

View file

@ -111,7 +111,7 @@ export function HeaderBar({ onStartScan, onOpenPalette }: HeaderBarProps) {
className="btn btn-primary btn-sm"
onClick={onStartScan}
>
Start Scan
Start scan
</button>
)}
</div>

View file

@ -14,6 +14,7 @@ import {
import type { FC } from 'react';
import type { IconProps } from '../icons/Icons';
import { useHealth } from '../../api/queries/health';
import { useOverview } from '../../api/queries/overview';
import { useSSE } from '../../contexts/SSEContext';
interface NavItem {
@ -89,17 +90,19 @@ function navLinkClass({ isActive }: { isActive: boolean }) {
export function Sidebar() {
const { data: health } = useHealth();
const { data: overview } = useOverview();
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');
const findingsCount =
overview && overview.state !== 'empty' ? overview.total_findings : null;
return (
<aside className="sidebar">
<div className="sidebar-header">
<span className="logo">nyx</span>
{health?.version && <span className="version">v{health.version}</span>}
<img src="/logo.png" alt="Nyx" className="sidebar-logo-img" />
</div>
<ul className="nav-list">
@ -113,12 +116,15 @@ export function Sidebar() {
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
<span className="nav-label">{item.label}</span>
{item.id === 'findings' && findingsCount != null && (
<span className="nav-badge">{findingsCount}</span>
)}
</NavLink>
</li>
))}
<li className="nav-separator" />
<li className="nav-section-header">Tools</li>
{secondary.map((item) => (
<li key={item.id}>
@ -126,7 +132,7 @@ export function Sidebar() {
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
<span className="nav-label">{item.label}</span>
</NavLink>
</li>
))}
@ -140,7 +146,7 @@ export function Sidebar() {
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
<span className="nav-label">{item.label}</span>
</NavLink>
</li>
))}

View file

@ -1,3 +1,4 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import type {
HealthScore,
@ -25,8 +26,17 @@ export function HealthScoreCard({
posture?: PostureSummary;
}) {
const gradeClass = `grade-${health.grade.toLowerCase()}`;
const gradeAccent =
health.grade === 'A' || health.grade === 'B'
? 'var(--green)'
: health.grade === 'C'
? 'var(--amber)'
: 'var(--red)';
return (
<div className="card health-card">
<div
className="card health-card"
style={{ '--health-accent': gradeAccent } as React.CSSProperties}
>
<div className="health-eyebrow">Health Score</div>
<div className="health-headline">
<div className={`health-grade-block ${gradeClass}`}>
@ -44,12 +54,26 @@ export function HealthScoreCard({
)}
</div>
<div className="health-components">
{health.components.map((c) => (
<div className="health-component" key={c.label} title={c.detail}>
<div className="health-component-score">{c.score}</div>
<div className="health-component-label">{c.label}</div>
</div>
))}
{health.components.map((c) => {
const barColor =
c.score >= 70
? 'var(--green)'
: c.score >= 40
? 'var(--amber)'
: 'var(--red)';
return (
<div className="health-component" key={c.label} title={c.detail}>
<div className="health-component-label">{c.label}</div>
<div className="health-component-bar-track">
<div
className="health-component-fill"
style={{ width: `${c.score}%`, background: barColor }}
/>
</div>
<div className="health-component-score">{c.score}</div>
</div>
);
})}
</div>
</div>
</div>
@ -113,7 +137,13 @@ export function BacklogCard({ backlog }: { backlog: BacklogStats }) {
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'];
const colors = [
'var(--accent)',
'var(--green)',
'var(--amber)',
'var(--red)',
'var(--muted)',
];
return (
<div
className="bucket-bar"
@ -149,10 +179,10 @@ export function ConfidenceDistributionChart({
);
}
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' },
{ label: 'High', value: dist.high, color: 'var(--green)' },
{ label: 'Medium', value: dist.medium, color: 'var(--amber)' },
{ label: 'Low', value: dist.low, color: 'var(--muted)' },
{ label: 'None', value: dist.none, color: 'var(--subtle)' },
];
return (
<div className="confidence-dist">
@ -484,7 +514,7 @@ export function SuppressionHygieneCard({
<div className="kv-row kv-row-emphasis">
<dt
className="kv-label"
title="Share of suppressions that are not pinned to a specific finding fingerprint. Lower is better — it means triage is decisive rather than blanket-silencing whole rules or files."
title="Share of suppressions that are not pinned to a specific finding fingerprint. Lower is better because triage is decisive rather than blanket-silencing whole rules or files."
>
Blanket rate
<span className="kv-hint">Lower is better</span>

View file

@ -12,7 +12,7 @@ export interface PaletteCommand {
id: string;
/** Visible label. */
label: string;
/** Optional secondary line — section, hint, shortcut. */
/** Optional secondary line such as section, hint, or shortcut. */
hint?: string;
/** Group label for visual separation. */
group?: string;

View file

@ -3,7 +3,7 @@ interface LoadingStateProps {
/**
* 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
* always something, but the visible spin only kicks in if work is
* actually slow.
*/
delaySpinnerMs?: number;

View file

@ -38,7 +38,7 @@ function resolve(pref: ThemePreference): ResolvedTheme {
export function ThemeProvider({ children }: { children: ReactNode }) {
const [preference, setPreference] = usePersistedState<ThemePreference>(
'theme',
'system',
'light',
);
const resolved = useMemo(() => resolve(preference), [preference]);

View file

@ -24,7 +24,7 @@ interface ToastContextValue {
t: Omit<Toast, 'id' | 'durationMs'> & { durationMs?: number },
) => number;
dismiss: (id: number) => void;
/** Convenience helpers — call sites read more naturally as toast.error('…'). */
/** Convenience helpers so call sites read naturally as toast.error('...'). */
info: (message: string, title?: string) => number;
success: (message: string, title?: string) => number;
warning: (message: string, title?: string) => number;
@ -37,7 +37,7 @@ const DEFAULT_DURATION: Record<ToastTone, number> = {
info: 4000,
success: 4000,
warning: 6000,
// Error toasts stick longer failures usually need a deliberate read.
// Error toasts stick longer because failures usually need a deliberate read.
error: 8000,
};

View file

@ -18,20 +18,20 @@ export interface EdgeStyle {
}
const FALLBACK_PALETTE: GraphThemePalette = {
background: '#ffffff',
backgroundSecondary: '#f7f7f8',
text: '#1a1a1a',
textSecondary: '#6b6b76',
textTertiary: '#9b9ba7',
border: '#e5e5ea',
borderLight: '#f0f0f4',
accent: '#5856d6',
accentSoft: '#ededfc',
success: '#2ecc71',
warning: '#e67e22',
danger: '#e74c3c',
neutral: '#607187',
neutralSoft: '#8c99ab',
background: '#f9f8f4',
backgroundSecondary: '#f2f0ea',
text: '#0d0c0a',
textSecondary: '#3c3830',
textTertiary: '#6c6660',
border: '#e5e1d7',
borderLight: '#ede9df',
accent: '#0b3d2a',
accentSoft: '#ecf3ee',
success: '#1c5c38',
warning: '#8c6310',
danger: '#9d2f25',
neutral: '#6c6660',
neutralSoft: '#9c9690',
};
function readVar(name: string, fallback: string): string {
@ -150,14 +150,14 @@ function cfgNodeStyle(
};
case 'Loop':
return {
fill: '#4f78c2',
stroke: '#3c5f9a',
fill: '#6c6660',
stroke: '#3c3830',
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 2.1,
accentFill: palette.accent,
neighborFill: withAlpha('#4f78c2', 0.74),
neighborFill: withAlpha('#6c6660', 0.74),
};
case 'Call':
return {
@ -200,8 +200,8 @@ function callGraphNodeStyle(
metadata?: GraphMetadata,
): NodeStyle {
const isRecursive = metadata?.isRecursive === true;
const fill = isRecursive ? '#7d6450' : palette.neutral;
const stroke = isRecursive ? '#6a5444' : withAlpha(palette.neutral, 0.84);
const fill = isRecursive ? '#5a5042' : palette.neutral;
const stroke = isRecursive ? '#3c3830' : withAlpha(palette.neutral, 0.84);
return {
fill,
@ -245,7 +245,7 @@ export function getEdgeStyle(
case 'False':
return { color: palette.danger, width: 1.8, dash: [] };
case 'Back':
return { color: '#4f78c2', width: 1.6, dash: [7, 4] };
return { color: palette.textTertiary, width: 1.6, dash: [7, 4] };
case 'Exception':
return { color: palette.warning, width: 1.6, dash: [3, 3] };
default:

View file

@ -31,7 +31,7 @@ const FINDINGS_DEFAULTS: FindingsURLState = {
};
/** Subset of state we remember across sessions. Filters intentionally are
* NOT persisted they're scan-specific and should reset by default, but the
* NOT persisted because 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;

View file

@ -13,7 +13,7 @@ export interface Shortcut {
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.
* contenteditable. Default is false, so shortcuts should not hijack typing.
*/
allowInInput?: boolean;
}

View file

@ -20,7 +20,7 @@ function write<T>(key: string, value: T): void {
try {
window.localStorage.setItem(storageKey(key), JSON.stringify(value));
} catch {
// Quota exceeded or storage disabled silently degrade.
// Quota exceeded or storage disabled, so silently degrade.
}
}
@ -28,7 +28,7 @@ function write<T>(key: string, value: T): void {
* `useState` that persists to `localStorage` under `nyx:<key>`.
*
* Suitable for view preferences (theme, sidebar collapse, default page size).
* Not suitable for sensitive data `localStorage` is not encrypted.
* Not suitable for sensitive data because `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.

View file

@ -18,7 +18,7 @@ interface NewScanModalProps {
const MODE_HINTS: Record<ScanMode, string> = {
full: 'AST + CFG + taint (default)',
ast: 'AST patterns only — fastest',
ast: 'AST patterns only. Fastest.',
cfg: 'CFG structural + taint',
taint: 'Taint flows only',
};
@ -26,7 +26,7 @@ const MODE_HINTS: Record<ScanMode, string> = {
const PROFILE_HINTS: Record<EngineProfile, string> = {
fast: 'Basic taint. No abstract-interp / context-sensitive / symex / backwards.',
balanced: 'Default. Adds abstract-interp + context-sensitive inlining.',
deep: 'Adds symex (cross-file + interproc) and demand-driven backwards taint. ~23× slower.',
deep: 'Adds symex (cross-file + interproc) and demand-driven backwards taint. About 2 to 3x slower.',
};
export function NewScanModal({ open, onClose }: NewScanModalProps) {
@ -67,7 +67,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
return (
<Modal open={open} onClose={onClose} className="scan-modal-overlay">
<div className="scan-modal">
<h3>Start New Scan</h3>
<h3>Start new scan</h3>
<div className="scan-modal-form">
<div className="form-group">
<label>Scan Root</label>
@ -114,7 +114,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
onClick={handleStart}
disabled={startScan.isPending}
>
{startScan.isPending ? 'Starting...' : 'Start Scan'}
{startScan.isPending ? 'Starting...' : 'Start scan'}
</button>
</div>
</div>

View file

@ -170,7 +170,7 @@ function KvGrid({ entries }: { entries: Array<[string, React.ReactNode]> }) {
}
function fmt(v: unknown): React.ReactNode {
if (v === null || v === undefined) return <span className="muted"></span>;
if (v === null || v === undefined) return <span className="muted">-</span>;
if (typeof v === 'boolean')
return (
<span className={v ? 'pill pill-on' : 'pill pill-off'}>
@ -387,7 +387,7 @@ function RawEditor() {
value={draft ?? ''}
spellCheck={false}
onChange={(e) => setDraft(e.target.value)}
placeholder="# nyx.local overrides for the default config.&#10;# Anything you set here wins over nyx.conf."
placeholder="# nyx.local - overrides for the default config.&#10;# Anything you set here wins over nyx.conf."
/>
<p className="config-help">
Edits are validated against the full config schema before being written.
@ -608,7 +608,7 @@ export function ConfigPage() {
setProfileName('');
}, [profileName, addProfile]);
if (configLoading) return <LoadingState message="Loading configuration" />;
if (configLoading) return <LoadingState message="Loading configuration..." />;
if (configError) return <ErrorState message={configError.message} />;
const cfg = config as Record<string, Record<string, unknown>> | undefined;
@ -616,15 +616,7 @@ export function ConfigPage() {
const triageSyncOn = !!server?.triage_sync;
return (
<>
<div className="page-header">
<h2>Config</h2>
<span className="page-header-sub">
Edit defaults, rules, profiles, and the raw <code>nyx.local</code>{' '}
file
</span>
</div>
<div className="config-page page-shell">
<div className="config-tabs">
{(
[
@ -866,6 +858,6 @@ export function ConfigPage() {
)}
{tab === 'raw' && <RawEditor />}
</>
</div>
);
}

View file

@ -67,7 +67,7 @@ const STATE_REMEDIATION_HINTS: Record<string, string[]> = {
'Prefer a language-native cleanup pattern (defer, with, try-with-resources, RAII).',
],
'state-resource-leak-possible': [
'Ensure the resource is closed on all code paths including error and early-return paths.',
'Ensure the resource is closed on all code paths, including error and early-return paths.',
'Put cleanup in a finally/defer block rather than after the happy path.',
],
'state-unauthed-access': [
@ -636,7 +636,7 @@ const TAINT_REMEDIATION: Record<string, string[]> = {
'If HTML is unavoidable, run input through a well-maintained sanitizer (DOMPurify, Bleach).',
],
sql: [
'Use parameterized queries or a prepared statement — never concatenate user input into SQL.',
'Use parameterized queries or a prepared statement. Never concatenate user input into SQL.',
'Prefer an ORM or query builder that escapes parameters automatically.',
'Validate input type (integer, enum, allowlist) before the query.',
],
@ -878,6 +878,12 @@ export function FindingDetailPage() {
const hasRelated = f.related_findings && f.related_findings.length > 0;
const hasLabels = f.labels && f.labels.length > 0;
const hasCode = !!f.code_context;
const sourcePath = evidence?.source
? `${evidence.source.path}:${evidence.source.line}:${evidence.source.col}`
: null;
const sinkPath = evidence?.sink
? `${evidence.sink.path}:${evidence.sink.line}:${evidence.sink.col}`
: null;
const metaParts: string[] = [];
if (f.category) metaParts.push(f.category);
@ -894,7 +900,7 @@ export function FindingDetailPage() {
}
return (
<div className="detail-panel finding-detail">
<div className="detail-panel finding-detail page-shell">
<div className="detail-title-row">
<h2 className="finding-heading">
<span
@ -931,6 +937,22 @@ export function FindingDetailPage() {
))}
</div>
{(sourcePath || sinkPath) && (
<div className="path-trace" aria-label="Source to sink path">
<div className="path-trace-card">
<span className="path-trace-label">Source</span>
<code className="path-trace-path">{sourcePath || 'Unknown'}</code>
</div>
<div className="path-trace-arrow" aria-hidden>
&rarr;
</div>
<div className="path-trace-card">
<span className="path-trace-label">Sink</span>
<code className="path-trace-path">{sinkPath || 'Unknown'}</code>
</div>
</div>
)}
<StatusControl
finding={f}
onTriage={handleTriage}
@ -974,13 +996,6 @@ export function FindingDetailPage() {
<HowToFix finding={f} />
</CollapsibleSection>
{/* Evidence (collapsed by default — overlaps with taint flow) */}
{hasEvidence && (
<CollapsibleSection title="Evidence" defaultOpen={false}>
<EvidenceSection evidence={evidence!} skipStateCard={isState} />
</CollapsibleSection>
)}
{/* Analysis Notes */}
{hasNotes && (
<CollapsibleSection title="Analysis Notes" defaultOpen={false}>
@ -1002,20 +1017,6 @@ export function FindingDetailPage() {
</CollapsibleSection>
)}
{/* Labels */}
{hasLabels && (
<CollapsibleSection title="Labels" defaultOpen={false}>
<div className="label-list">
{f.labels.map(([k, v], i) => (
<span key={i} className="label-item">
<span className="label-key">{k}:</span>{' '}
<span className="label-value">{v}</span>
</span>
))}
</div>
</CollapsibleSection>
)}
{/* Code Preview */}
{hasCode && (
<CollapsibleSection title="Code Preview" defaultOpen={false}>

View file

@ -567,15 +567,7 @@ export function FindingsPage() {
const totalPages = Math.ceil(data.total / data.per_page) || 1;
return (
<>
<div className="page-header">
<h2>Findings</h2>
<span className="filter-count">
{data.total} finding{data.total !== 1 ? 's' : ''}
{hasActiveFilters ? ' (filtered)' : ''}
</span>
</div>
<div className="findings-page page-shell">
{/* Filter bar */}
<div className="filter-bar">
<input
@ -793,6 +785,6 @@ export function FindingsPage() {
onClose={() => setSuppressModalOpen(false)}
/>
)}
</>
</div>
);
}

View file

@ -64,7 +64,7 @@ export function OverviewPage() {
const categoryItems = (overview.issue_categories || [])
.slice(0, 8)
.map((b) => ({ label: b.label, value: b.count, color: '#5856d6' }));
.map((b) => ({ label: b.label, value: b.count, color: 'var(--accent)' }));
const trendData = (trends || []).map((t) => ({
label: t.timestamp,
@ -74,11 +74,7 @@ export function OverviewPage() {
const hotSinks = overview.hot_sinks || [];
return (
<>
<div className="page-header">
<h2>Overview</h2>
</div>
<div className="overview-page page-shell">
{/* Baseline strip */}
<BaselinePinControl
baseline={overview.baseline}
@ -117,7 +113,7 @@ export function OverviewPage() {
</div>
)}
{/* Stat cards kept lean: 5 cards, severity stacks live in Top Files
{/* Stat cards kept lean: 5 cards, severity stacks live in Top Files
and Per-Language. Cross-file / Symex moved into Scanner Quality. */}
<div className="overview-stat-grid overview-stat-grid-5">
<StatCard
@ -145,7 +141,7 @@ export function OverviewPage() {
/>
</div>
{/* Charts */}
{/* Charts — 3-col: Findings (col1 span2) | OWASP+Confidence (col2) | Categories (col3 span2) */}
<div className="overview-chart-grid">
<div className="card">
<div className="card-header">Findings Over Time</div>
@ -158,14 +154,8 @@ export function OverviewPage() {
)}
</div>
<div className="card">
<div className="card-header">OWASP Top 10 (2021)</div>
{overview.owasp_buckets && overview.owasp_buckets.length > 0 ? (
<OwaspChart buckets={overview.owasp_buckets} />
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>No OWASP-mapped findings.</p>
</div>
)}
<div className="card-header">Issue Categories</div>
<HorizontalBarChart items={categoryItems} />
</div>
<div className="card">
<div className="card-header">Confidence Distribution</div>
@ -180,8 +170,14 @@ export function OverviewPage() {
)}
</div>
<div className="card">
<div className="card-header">Issue Categories</div>
<HorizontalBarChart items={categoryItems} />
<div className="card-header">OWASP Top 10 (2021)</div>
{overview.owasp_buckets && overview.owasp_buckets.length > 0 ? (
<OwaspChart buckets={overview.owasp_buckets} />
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>No OWASP-mapped findings.</p>
</div>
)}
</div>
</div>
@ -289,7 +285,7 @@ export function OverviewPage() {
)}
</div>
</div>
</>
</div>
);
}

View file

@ -271,20 +271,7 @@ export function RulesPage() {
if (error) return <ErrorState message={error.message} />;
return (
<>
<div className="page-header">
<h2>Rules</h2>
<span
style={{
color: 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
marginLeft: 'var(--space-3)',
}}
>
{(rules || []).length} rules
</span>
</div>
<div className="rules-page page-shell">
<div className="rules-layout">
<div className="rules-list-panel">
<div className="rules-filters">
@ -310,14 +297,7 @@ export function RulesPage() {
</option>
))}
</select>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'var(--text-sm)',
}}
>
<label className="rules-custom-toggle">
<input
type="checkbox"
checked={customOnly}
@ -357,6 +337,6 @@ export function RulesPage() {
)}
</div>
</div>
</>
</div>
);
}

View file

@ -221,7 +221,7 @@ function CompareByGroup({
}, [data, groupField]);
return (
<>
<div className="scan-compare-page page-shell">
{groups.map(([key, items]) => {
const counts = { new: 0, fixed: 0, changed: 0, unchanged: 0 };
items.forEach(
@ -267,7 +267,7 @@ function CompareByGroup({
</CollapsibleSection>
);
})}
</>
</div>
);
}
@ -300,16 +300,10 @@ export function ScanComparePage() {
return (
<>
<button
className="btn btn-sm"
style={{ marginBottom: 'var(--space-4)' }}
onClick={() => navigate('/scans')}
>
Back to Scans
</button>
<div className="page-header">
<h2>Scan Comparison</h2>
<div className="page-action-row">
<button className="btn btn-sm" onClick={() => navigate('/scans')}>
Back to Scans
</button>
</div>
<div className="compare-header">

View file

@ -71,125 +71,127 @@ function SummaryTab({ scan }: { scan: ScanView }) {
</div>
</div>
<div className="card">
<div className="card-header">Details</div>
<table>
<tbody>
<tr>
<td style={{ color: 'var(--text-secondary)', width: 140 }}>
Scan ID
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
}}
>
{scan.id}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Root</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-sm)',
}}
>
{scan.scan_root}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Engine</td>
<td>{scan.engine_version || '-'}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Started</td>
<td>{fmtDate(scan.started_at)}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Finished</td>
<td>{fmtDate(scan.finished_at)}</td>
</tr>
{scan.error && (
<div className="scan-summary-grid">
<div className="card scan-detail-card">
<div className="card-header">Details</div>
<table>
<tbody>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Error</td>
<td style={{ color: 'var(--sev-high)' }}>{scan.error}</td>
<td style={{ color: 'var(--text-secondary)', width: 140 }}>
Scan ID
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
}}
>
{scan.id}
</td>
</tr>
)}
</tbody>
</table>
</div>
{timing && total > 0 && (
<div className="card" style={{ marginTop: 'var(--space-4)' }}>
<div className="card-header">Timing Breakdown</div>
<div className="timing-bar">
<div
className="timing-bar-segment walk"
style={{ width: `${pct(timing.walk_ms)}%` }}
title={`Walk: ${timing.walk_ms}ms`}
></div>
<div
className="timing-bar-segment pass1"
style={{ width: `${pct(timing.pass1_ms)}%` }}
title={`Pass 1: ${timing.pass1_ms}ms`}
></div>
<div
className="timing-bar-segment callgraph"
style={{ width: `${pct(timing.call_graph_ms)}%` }}
title={`Call Graph: ${timing.call_graph_ms}ms`}
></div>
<div
className="timing-bar-segment pass2"
style={{ width: `${pct(timing.pass2_ms)}%` }}
title={`Pass 2: ${timing.pass2_ms}ms`}
></div>
<div
className="timing-bar-segment postprocess"
style={{ width: `${pct(timing.post_process_ms)}%` }}
title={`Post-process: ${timing.post_process_ms}ms`}
></div>
</div>
<div className="timing-legend">
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-low)' }}
></span>{' '}
Walk {timing.walk_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--accent)' }}
></span>{' '}
Pass 1 {timing.pass1_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-medium)' }}
></span>{' '}
Call Graph {timing.call_graph_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--success)' }}
></span>{' '}
Pass 2 {timing.pass2_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--text-tertiary)' }}
></span>{' '}
Post {timing.post_process_ms}ms
</span>
</div>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Root</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-sm)',
}}
>
{scan.scan_root}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Engine</td>
<td>{scan.engine_version || '-'}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Started</td>
<td>{fmtDate(scan.started_at)}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Finished</td>
<td>{fmtDate(scan.finished_at)}</td>
</tr>
{scan.error && (
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Error</td>
<td style={{ color: 'var(--sev-high)' }}>{scan.error}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{timing && total > 0 && (
<div className="card scan-timing-card">
<div className="card-header">Timing Breakdown</div>
<div className="timing-bar">
<div
className="timing-bar-segment walk"
style={{ width: `${pct(timing.walk_ms)}%` }}
title={`Walk: ${timing.walk_ms}ms`}
></div>
<div
className="timing-bar-segment pass1"
style={{ width: `${pct(timing.pass1_ms)}%` }}
title={`Pass 1: ${timing.pass1_ms}ms`}
></div>
<div
className="timing-bar-segment callgraph"
style={{ width: `${pct(timing.call_graph_ms)}%` }}
title={`Call Graph: ${timing.call_graph_ms}ms`}
></div>
<div
className="timing-bar-segment pass2"
style={{ width: `${pct(timing.pass2_ms)}%` }}
title={`Pass 2: ${timing.pass2_ms}ms`}
></div>
<div
className="timing-bar-segment postprocess"
style={{ width: `${pct(timing.post_process_ms)}%` }}
title={`Post-process: ${timing.post_process_ms}ms`}
></div>
</div>
<div className="timing-legend">
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-low)' }}
></span>{' '}
Walk {timing.walk_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--accent)' }}
></span>{' '}
Pass 1 {timing.pass1_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-medium)' }}
></span>{' '}
Call Graph {timing.call_graph_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--success)' }}
></span>{' '}
Pass 2 {timing.pass2_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--text-tertiary)' }}
></span>{' '}
Post {timing.post_process_ms}ms
</span>
</div>
</div>
)}
</div>
</>
);
}
@ -212,7 +214,7 @@ function FindingsTab({ scanId }: { scanId: string }) {
}
return (
<>
<div className="scan-detail-page page-shell">
<div className="table-wrap">
<table>
<thead>
@ -266,7 +268,7 @@ function FindingsTab({ scanId }: { scanId: string }) {
>
Showing {data.findings.length} of {data.total} findings
</div>
</>
</div>
);
}
@ -416,31 +418,22 @@ export function ScanDetailPage() {
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
marginBottom: 'var(--space-4)',
}}
>
<div className="page-action-row">
<button className="btn btn-sm" onClick={() => navigate('/scans')}>
Back to Scans
</button>
{prevScanId && (
<button
className="btn btn-sm"
style={{ marginLeft: 'auto' }}
className="btn btn-sm page-action-push"
onClick={() => navigate(`/scans/compare/${prevScanId}/${id}`)}
>
Compare with Previous
</button>
)}
</div>
<div className="page-header">
<h2>Scan Detail</h2>
<span className={`status-badge ${scan.status}`}>
<span
className={`status-badge ${scan.status}`}
style={{ marginLeft: 'auto' }}
>
<span className={`status-dot ${scan.status}`}></span>
{scan.status}
</span>

View file

@ -174,11 +174,7 @@ export function ScansPage() {
const showCheckboxes = completedScans.length >= 2;
return (
<>
<div className="page-header">
<h2>Scans</h2>
</div>
<div className="scans-page page-shell">
{(runningScans.length > 0 || isScanRunning) && scanProgress && (
<ScanProgress data={scanProgress} />
)}
@ -210,90 +206,103 @@ export function ScansPage() {
</div>
) : (
<div className="table-wrap">
<table>
<table className="scans-table">
<thead>
<tr>
{showCheckboxes && <th style={{ width: 32 }}></th>}
<th>Status</th>
<th>Root</th>
<th>Duration</th>
<th>Findings</th>
<th>Languages</th>
<th>Started</th>
<th style={{ width: 60 }}></th>
{showCheckboxes && <th className="scan-select-col"></th>}
<th className="scan-status-col">Status</th>
<th className="scan-root-col">Root</th>
<th className="scan-duration-col">Duration</th>
<th className="scan-findings-col">Findings</th>
<th className="scan-languages-col">Languages</th>
<th className="scan-started-col">Started</th>
<th className="scan-actions-col"></th>
</tr>
</thead>
<tbody>
{scans.map((s: ScanView) => (
<tr
key={s.id}
className="clickable"
onClick={() => navigate(`/scans/${s.id}`)}
>
{showCheckboxes && (
{scans.map((s: ScanView) => {
const languages = s.languages || [];
const visibleLanguages = languages.slice(0, 4);
const hiddenLanguageCount =
languages.length - visibleLanguages.length;
return (
<tr
key={s.id}
className="clickable"
onClick={() => navigate(`/scans/${s.id}`)}
>
{showCheckboxes && (
<td>
{s.status === 'completed' && (
<input
type="checkbox"
className="scan-compare-cb"
checked={selectedScans.has(s.id)}
onClick={(e) => handleCheckbox(e, s.id)}
onChange={() => {}}
/>
)}
</td>
)}
<td>
{s.status === 'completed' && (
<input
type="checkbox"
className="scan-compare-cb"
checked={selectedScans.has(s.id)}
onClick={(e) => handleCheckbox(e, s.id)}
onChange={() => {}}
/>
<span className={`status-badge ${s.status}`}>
<span className={`status-dot ${s.status}`}></span>
{s.status}
</span>
</td>
<td className="scan-root-cell" title={s.scan_root}>
{truncPath(s.scan_root)}
</td>
<td className="scan-number-cell">
{s.duration_secs != null
? s.duration_secs.toFixed(2) + 's'
: '-'}
</td>
<td className="scan-number-cell">
{s.finding_count ?? '-'}
</td>
<td className="scan-languages-cell">
{languages.length > 0 ? (
<span className="scan-language-list">
{visibleLanguages.map((l) => (
<span key={l} className="lang-badge">
{l}
</span>
))}
{hiddenLanguageCount > 0 && (
<span className="lang-badge lang-badge-more">
+{hiddenLanguageCount}
</span>
)}
</span>
) : (
'-'
)}
</td>
)}
<td>
<span className={`status-badge ${s.status}`}>
<span className={`status-dot ${s.status}`}></span>
{s.status}
</span>
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.82rem',
}}
>
{truncPath(s.scan_root)}
</td>
<td>
{s.duration_secs != null
? s.duration_secs.toFixed(2) + 's'
: '-'}
</td>
<td>{s.finding_count ?? '-'}</td>
<td>
{(s.languages || []).length > 0
? (s.languages || []).map((l) => (
<span key={l} className="lang-badge">
{l}
</span>
))
: '-'}
</td>
<td>{relTime(s.started_at)}</td>
<td>
{s.status !== 'running' && (
<button
className="btn btn-sm btn-danger"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this scan?')) {
deleteScan.mutate(s.id);
}
}}
>
Delete
</button>
)}
</td>
</tr>
))}
<td>{relTime(s.started_at)}</td>
<td className="scan-actions-cell">
{s.status !== 'running' && (
<button
className="btn btn-sm btn-danger"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this scan?')) {
deleteScan.mutate(s.id);
}
}}
>
Delete
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
</div>
);
}

View file

@ -130,6 +130,22 @@ function TriageSummary({
<div className="triage-hero">
<div className="triage-hero-row">
<h1 className="triage-hero-title">{headline}</h1>
{showSeverity && totalCount > 0 && (
<div className="triage-hero-severity">
{SEVERITIES.map((sev) => (
<span
key={sev}
className={`triage-sev-stat triage-sev-${sev.toLowerCase()}`}
>
<span className="triage-sev-dot" aria-hidden />
<span className="triage-sev-count">
{(openBySev[sev] ?? 0).toLocaleString()}
</span>
<span className="triage-sev-name">{sev}</span>
</span>
))}
</div>
)}
{totalCount > 0 && (
<button
type="button"
@ -142,22 +158,6 @@ function TriageSummary({
</button>
)}
</div>
{showSeverity && totalCount > 0 && (
<div className="triage-hero-severity">
{SEVERITIES.map((sev) => (
<span
key={sev}
className={`triage-sev-stat triage-sev-${sev.toLowerCase()}`}
>
<span className="triage-sev-dot" aria-hidden />
<span className="triage-sev-count">
{(openBySev[sev] ?? 0).toLocaleString()}
</span>
<span className="triage-sev-name">{sev}</span>
</span>
))}
</div>
)}
{expanded && (
<div className="triage-state-row">
<button
@ -1124,7 +1124,7 @@ export function TriagePage() {
GROUP_MODES.find((g) => g.value === groupMode)?.label ?? 'None';
return (
<div className="triage-page">
<div className="triage-page page-shell">
<TriageSummary
totalCount={totalCount}
needsAttention={needsAttention}
@ -1260,7 +1260,7 @@ export function TriagePage() {
<input
className="triage-search"
type="search"
placeholder="Search rule, file, message"
placeholder="Search rule, file, message..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>

View file

@ -85,7 +85,7 @@ export function FunctionSelector({
}
function formatFunctionLabel(fn: FunctionInfo): string {
const sig = `(${fn.param_count} params) L${fn.line}`;
const sig = `(${fn.param_count} params), L${fn.line}`;
if (fn.func_kind === 'closure' && fn.container) {
return `${fn.name} [closure in ${fn.container}] ${sig}`;
}

View file

@ -63,7 +63,7 @@ export function TypeFactsAnalysisPanel({
{securityFacts.length > 0 && (
<TypeFactGroup
title="Security-Relevant Types"
subtitle="HttpClient, DatabaseConnection, Url, … — drive type-qualified callee resolution and sink suppression"
subtitle="HttpClient, DatabaseConnection, Url, and related types drive type-qualified callee resolution and sink suppression"
facts={securityFacts}
highlight
/>

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ import { getNodeStyle, getEdgeStyle } from '@/graph/styles';
describe('getNodeStyle', () => {
it('returns a style for Entry nodes', () => {
const s = getNodeStyle('Entry');
expect(s.fill).toBe('#2ecc71');
expect(s.fill).toBe('#1c5c38');
expect(s.shape).toBe('double');
});
@ -47,26 +47,26 @@ describe('getNodeStyle', () => {
it('returns a specialized style for recursive call graph nodes', () => {
const s = getNodeStyle('Call', 'callgraph', { isRecursive: true });
expect(s.fill).toBe('#7d6450');
expect(s.fill).toBe('#5a5042');
});
});
describe('getEdgeStyle', () => {
it('returns green color for True edges', () => {
const s = getEdgeStyle('True');
expect(s.color).toBe('#2ecc71');
expect(s.color).toBe('#1c5c38');
expect(s.dash).toEqual([]);
});
it('returns red color for False edges', () => {
const s = getEdgeStyle('False');
expect(s.color).toBe('#e74c3c');
expect(s.color).toBe('#9d2f25');
expect(s.dash).toEqual([]);
});
it('returns dashed style for Back edges', () => {
const s = getEdgeStyle('Back');
expect(s.color).toBe('#4f78c2');
expect(s.color).toBe('#6c6660');
expect(s.dash).toEqual([7, 4]);
});

View file

@ -44,7 +44,7 @@ const full: FindingView = {
language: 'python',
status: 'new',
triage_state: 'investigating',
triage_note: 'Looks real assigned to alice.',
triage_note: 'Looks real - assigned to alice.',
code_context: {
start_line: 138,
highlight_line: 141,
@ -120,7 +120,7 @@ const full: FindingView = {
describe('findingToMarkdown', () => {
it('renders the full finding with all sections', () => {
const md = findingToMarkdown(full);
expect(md).toContain('## py-sqli User input flows into SQL query.');
expect(md).toContain('## py-sqli - User input flows into SQL query.');
expect(md).toContain('- **Rule**: `py-sqli` (category: `sqli`)');
expect(md).toContain('- **Severity**: High | **Confidence**: High');
expect(md).toContain('- **Location**: `src/handlers/login.py:141:10`');
@ -130,7 +130,7 @@ describe('findingToMarkdown', () => {
expect(md).toContain('### Explanation\nUntrusted input reaches');
expect(md).toContain('### Evidence');
expect(md).toContain(
'**Source** `src/handlers/login.py:138:7` (kind: UserInput)',
'**Source**: `src/handlers/login.py:138:7` (kind: UserInput)',
);
expect(md).toContain('```python\nrequest.args.get("name")\n```');
expect(md).toContain('**Guards**: none');
@ -146,19 +146,19 @@ describe('findingToMarkdown', () => {
expect(md).toContain('### Notes');
expect(md).toContain('- Source type: User Input');
expect(md).toContain('- Path length: 3 blocks');
expect(md).toContain('### Triage note\nLooks real assigned to alice.');
expect(md).toContain('### Triage note\nLooks real - assigned to alice.');
expect(md).toContain('### Confidence reasoning');
expect(md).toContain('Score: 8.7');
expect(md).toContain('- **source_kind**: direct user input');
expect(md).toContain('### Related findings');
expect(md).toContain(
'- `#99` `py-xss` `src/handlers/login.py:160` (Medium)',
'- `#99` `py-xss` - `src/handlers/login.py:160` (Medium)',
);
});
it('skips optional sections for a lean finding', () => {
const md = findingToMarkdown(lean);
expect(md).toContain('## js-xss xss');
expect(md).toContain('## js-xss - xss');
expect(md).toContain('**Confidence**: unknown');
expect(md).not.toContain('### Message');
expect(md).not.toContain('### Evidence');

View file

@ -41,10 +41,10 @@ function formatEvidence(ev: Evidence, lang: string | undefined): string {
if (hasSpans) {
const lines: string[] = ['### Evidence'];
if (ev.source) {
lines.push(`**Source** ${formatSpan(ev.source, lang)}`);
lines.push(`**Source**: ${formatSpan(ev.source, lang)}`);
}
if (ev.sink) {
lines.push(`**Sink** ${formatSpan(ev.sink, lang)}`);
lines.push(`**Sink**: ${formatSpan(ev.sink, lang)}`);
}
if (ev.source || ev.sink) {
if (!ev.guards || ev.guards.length === 0) {
@ -68,7 +68,7 @@ function formatEvidence(ev: Evidence, lang: string | undefined): string {
const st = ev.state;
const subj = st.subject ? ` ${st.subject}:` : '';
lines.push(
`**State**: ${st.machine} ${subj} ${st.from_state} ${st.to_state}`,
`**State**: ${st.machine} -${subj} ${st.from_state} -> ${st.to_state}`,
);
}
parts.push(lines.join('\n'));
@ -87,7 +87,7 @@ function formatFlow(steps: FlowStep[]): string {
const lines: string[] = [`### Flow (${steps.length} steps)`];
for (const s of steps) {
const segs: string[] = [`${s.step}. **${s.kind}** \`${s.file}:${s.line}\``];
if (s.snippet) segs.push(` \`${s.snippet}\``);
if (s.snippet) segs.push(`- \`${s.snippet}\``);
if (s.variable) segs.push(`(var \`${s.variable}\`)`);
if (s.callee) segs.push(`(callee \`${s.callee}\`)`);
if (s.is_cross_file) segs.push(`[cross-file]`);
@ -117,7 +117,7 @@ function formatRelated(related: RelatedFindingView[]): string {
const lines: string[] = ['### Related findings'];
for (const r of related) {
lines.push(
`- \`#${r.index}\` \`${r.rule_id}\` \`${r.path}:${r.line}\` (${r.severity})`,
`- \`#${r.index}\` \`${r.rule_id}\` - \`${r.path}:${r.line}\` (${r.severity})`,
);
}
return lines.join('\n');
@ -128,7 +128,7 @@ export function findingToMarkdown(f: FindingView): string {
const heading = firstLine(f.message || '').trim() || f.category;
const parts: string[] = [];
parts.push(`## ${f.rule_id} ${heading}`);
parts.push(`## ${f.rule_id} - ${heading}`);
const meta: string[] = [];
meta.push(`- **Rule**: \`${f.rule_id}\` (category: \`${f.category}\`)`);

View file

@ -8,7 +8,7 @@ Local helpers for repo-wide checks and a couple of one-off tools.
| `check.sh` | Verify only (no fixes). Mirrors the GitHub Actions CI workflow. |
| `cached-cargo-test.sh` | Wrap `cargo test` with a source-hash cache; concurrent invocations of the same args share one run. |
| `capture-screenshots.mjs`| Capture the README stills and demo GIF from a running `nyx serve`. Needs Playwright and ffmpeg. |
| `frame-screenshots.py` | Wrap a PNG in the brand purple gradient. Called by `capture-screenshots.mjs` as its final phase, but can be run standalone. |
| `frame-screenshots.py` | Wrap a PNG in the brand mint-cyan gradient. Called by `capture-screenshots.mjs` as its final phase, but can be run standalone. |
Fixers stream their output (so you can see what changed); tests run quietly and
only show output if they fail. Both scripts print a green/red summary at the end
@ -73,8 +73,9 @@ Stills are captured in two phases:
`serve-scan-detail.png`, `serve-rules.png`, `serve-config.png`.
Then `frame-screenshots.py` runs over every captured PNG and wraps it in
the brand purple gradient (1800x1113 outer, 1600x992 inner, 12px rounded
corners, top-left `#8a5bf5` to bottom-right `#4d1d97`). Finally,
the brand mint-led four-corner gradient (1800x1113 outer, 1600x992 inner,
12px rounded corners: TL `#72f3d7`, TR `#ff6aa2`, BL `#f8c56b`, BR
`#4cc9ff`). Finally,
`docs/serve-overview.png` is copied to the top-level `overview.png`
because that is the path the README references.

316
scripts/capture-screenshots.mjs Normal file → Executable file
View file

@ -21,7 +21,7 @@
* two-scan history (overview trend, scans list,
* scan detail) plus the static-ish ones
* (triage, explorer, rules, config)
* 7. frame composite the brand purple gradient around every
* 7. frame composite the brand mint-cyan gradient around every
* captured PNG via scripts/frame-screenshots.py
*
* Prerequisites (script asserts each before starting):
@ -37,8 +37,12 @@
* node scripts/capture-screenshots.mjs --all # both, in one orchestrated run
*
* Output (under assets/screenshots/):
* demo.gif (~2530s walkthrough)
* demo.gif (~2530s serve walkthrough)
* demo_raw.gif (unframed source saved before compositing)
* cli-scan.gif (~15s CLI scan walkthrough requires vhs on PATH)
* cli-scan_raw.gif (unframed source)
* overview.png (mirror of docs/serve-overview.png; used by README)
* *_raw.png / *_raw.gif (unframed originals for every captured asset)
* docs/serve-overview.png (overview after scan #2 trend going down)
* docs/serve-findings-list.png (post-scan-#1 list with multiple highs)
* docs/serve-finding-detail.png (5-hop taint flow visualizer)
@ -49,16 +53,17 @@
* docs/serve-rules.png
* docs/serve-config.png
*/
import { execFileSync } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { extname, join } from 'node:path';
import process from 'node:process';
const URL_BASE = process.env.NYX_URL || 'http://127.0.0.1:9876';
@ -66,15 +71,19 @@ const SCAN_ROOT = process.env.SCAN_ROOT || '/tmp/nyx-demo-app';
const OUT_DIR = process.env.OUT_DIR || '/Users/elipeter/nyx/assets/screenshots';
const FRAMER = process.env.FRAMER || '/Users/elipeter/nyx/scripts/frame-screenshots.py';
const NYX_BIN = process.env.NYX_BIN || '/Users/elipeter/nyx/target/release/nyx';
const VIEW = { width: 1440, height: 900 };
// Sibling marketing site that mirrors a small subset of these assets.
// Set NYXSCAN_DIR=skip to disable the mirror step.
const NYXSCAN_DIR = process.env.NYXSCAN_DIR || '/Users/elipeter/nyxscan.dev/assets/screenshots';
const VIEW = { width: 1600, height: 992 };
const COLOR_SCHEME = 'light';
const args = new Set(process.argv.slice(2));
const wantStills = args.has('--stills') || args.has('--all');
const wantGif = args.has('--gif') || args.has('--all');
const wantCli = args.has('--cli') || args.has('--all');
if (!wantStills && !wantGif && !wantCli) {
console.error('usage: capture-screenshots.mjs [--stills|--gif|--cli|--all]');
const wantCombo = args.has('--combo') || args.has('--all');
if (!wantStills && !wantGif && !wantCli && !wantCombo) {
console.error('usage: capture-screenshots.mjs [--stills|--gif|--cli|--combo|--all]');
process.exit(2);
}
@ -330,9 +339,9 @@ async function captureGifFrames(page) {
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
.catch(() => {});
await sleep(1800);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await sleep(800);
@ -387,18 +396,61 @@ async function captureGifFrames(page) {
await sleep(1500);
}
// Combo GIF browser storyboard — data already present from VHS scan phase -----
async function captureGifFramesCombo(page) {
console.error('[combo/gif] scene 1: overview with scan data');
await page.goto(URL_BASE + '/');
await page
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 15_000 })
.catch(() => {});
await sleep(2200);
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await sleep(900);
console.error('[combo/gif] scene 2: findings list');
await page.click('a.nav-link:has-text("Findings"), .sidebar a:has-text("Findings")');
await page.waitForURL('**/findings', { timeout: 10_000 });
await page.waitForSelector('tbody tr', { timeout: 10_000 });
await sleep(1500);
console.error('[combo/gif] scene 3: 5-hop taint finding detail');
const taintRow = await findFirstTaintRow(page);
await taintRow.click();
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
await sleep(2500);
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1600);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await sleep(1600);
console.error('[combo/gif] scene 4: open Evidence + Analysis Notes');
for (const title of ['Evidence', 'Analysis Notes']) {
const toggle = page.locator(`.section-toggle:has-text("${title}")`).first();
if (await toggle.count()) {
await toggle.scrollIntoViewIfNeeded();
await sleep(500);
await toggle.click();
await sleep(1000);
}
}
await sleep(1200);
}
async function convertWebmToGif(webm, gifOut) {
const palette = '/tmp/nyx-demo-palette.png';
console.error('[gif] generating palette');
execFileSync('ffmpeg', [
'-y', '-ss', '1.0', '-i', webm,
'-vf', 'fps=15,scale=1440:-1:flags=lanczos,palettegen',
'-vf', 'fps=15,scale=1280:-1:flags=lanczos,palettegen',
palette,
], { stdio: 'inherit' });
console.error('[gif] palette → gif');
execFileSync('ffmpeg', [
'-y', '-ss', '1.0', '-i', webm, '-i', palette,
'-lavfi', 'fps=15,scale=1440:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
'-lavfi', 'fps=15,scale=1280:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
gifOut,
], { stdio: 'inherit' });
}
@ -412,6 +464,8 @@ async function convertWebmToGif(webm, gifOut) {
// the framer never resamples the captured text.
const CLI_RENDERER = '/Users/elipeter/nyx/scripts/render-cli.py';
const VHS_BIN = process.env.VHS_BIN || 'vhs';
const CLI_GIF = join(OUT_DIR, 'cli-scan.gif');
function renderCli(shellCommand, outFile) {
execFileSync(
@ -449,9 +503,46 @@ function stageDemoConfigHome() {
writeFileSync(join(cfgDir, 'nyx.local'), DEMO_NYX_LOCAL);
}
function captureCliGif() {
console.error('[cli-gif/setup] writing v1 demo');
writeDemo('v1');
const tapePath = '/tmp/nyx-cli-scan.tape';
const innerGif = '/tmp/nyx-cli-scan.gif';
// VHS tape: terminal set to exact inner dimensions so frame-screenshots.py
// fixed-mode doesn't need to resample — 1600x992 matches INNER_W x INNER_H.
const tape = [
`Output "${innerGif}"`,
'',
'Set Shell "bash"',
'Set FontSize 22',
'Set Width 1600',
'Set Height 992',
'Set Framerate 15',
'Env CLICOLOR_FORCE "1"',
'',
'Sleep 500ms',
`Type "${NYX_BIN} scan ${SCAN_ROOT}"`,
'Sleep 300ms',
'Enter',
'Sleep 12s',
'Sleep 3s',
].join('\n');
writeFileSync(tapePath, tape);
console.error('[cli-gif] recording with vhs');
execFileSync(VHS_BIN, [tapePath], { stdio: 'inherit' });
copyFileSync(innerGif, CLI_GIF);
console.error(`[cli-gif] wrote ${CLI_GIF}`);
}
function captureCli() {
// Re-stage v1 so cli-scan output shows the richer set of findings
// (the previous --stills phase patched the demo to v2).
captureCliGif();
// Re-stage v1 so static cli-scan output shows the richer set of findings
// (captureCliGif already wrote v1; this is a safety re-stage in case
// the previous --stills phase patched the demo to v2).
console.error('[cli/setup] writing v1 demo');
writeDemo('v1');
@ -492,6 +583,143 @@ function captureCli() {
// without a much larger fixture; the existing image is left alone.
}
// Combo GIF ------------------------------------------------------------------
// Single GIF: CLI scan (VHS terminal) → hard cut → serve UI (Playwright).
// The VHS portion is a visual recording only — nyx scan (standalone CLI)
// writes to a separate store that nyx serve does not read. After VHS we
// wipe state and trigger a real scan through the serve API so Playwright
// has live data to explore.
async function captureComboGif() {
function wipeState() {
rmSync(join(SCAN_ROOT, '.nyx'), { recursive: true, force: true });
const homeDir = process.env.HOME || '/Users/elipeter';
const sysDbBase = join(homeDir, 'Library/Application Support/nyx/nyx-demo-app.sqlite');
for (const suffix of ['', '-wal', '-shm']) {
try { unlinkSync(sysDbBase + suffix); } catch {}
}
}
// 1. Clean state + write demo.
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
await sleep(800);
wipeState();
writeDemo('v1');
// 2. VHS: scan → results pause → type nyx serve → see it start.
const cliGifPath = '/tmp/nyx-combo-cli.gif';
const tapePath = '/tmp/nyx-combo.tape';
const tape = [
`Output "${cliGifPath}"`,
'',
'Set Shell "bash"',
'Set FontSize 22',
'Set Width 1600',
'Set Height 992',
'Set Framerate 15',
'Env CLICOLOR_FORCE "1"',
'',
'Sleep 500ms',
`Type "${NYX_BIN} scan ${SCAN_ROOT}"`,
'Sleep 300ms',
'Enter',
'Sleep 1500ms',
`Type "${NYX_BIN} serve --port 9876 --no-browser ${SCAN_ROOT}"`,
'Sleep 300ms',
'Enter',
'Sleep 2000ms',
].join('\n');
writeFileSync(tapePath, tape);
console.error('[combo] recording CLI portion with vhs');
execFileSync(VHS_BIN, [tapePath], { stdio: 'inherit' });
// 3. Wipe state again and start a fresh host serve. The VHS scan wrote
// to standalone storage that nyx serve doesn't read, so we drive a
// real scan through the serve API to populate the browser session.
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
await sleep(800);
wipeState();
const serveProc = spawn(NYX_BIN, [
'serve', '--port', '9876', '--no-browser', SCAN_ROOT,
], { detached: false, stdio: 'ignore' });
serveProc.unref();
await waitForServer();
const comboToken = await csrfToken();
const comboBefore = await currentScanId();
await startScanViaApi(comboToken);
await waitForScanComplete(comboBefore);
// 4. Playwright: record browser walkthrough against the live scan data.
const videoDir = '/tmp/nyx-combo-video';
if (existsSync(videoDir)) rmSync(videoDir, { recursive: true });
mkdirSync(videoDir, { recursive: true });
const { chromium } = await import('playwright');
const browser = await chromium.launch({ headless: true });
try {
const ctx = await browser.newContext({
viewport: VIEW,
colorScheme: COLOR_SCHEME,
recordVideo: { dir: videoDir, size: VIEW },
});
await ctx.addInitScript(() => {
try { localStorage.setItem('theme', 'light'); } catch {}
});
const page = await ctx.newPage();
await captureGifFramesCombo(page);
await page.close();
await ctx.close();
} finally {
await browser.close();
}
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
// 5. Find Playwright webm.
const webms = readdirSync(videoDir).filter((f) => f.endsWith('.webm'));
if (!webms.length) throw new Error('[combo] no webm captured for browser portion');
const webmPath = join(videoDir, webms[0]);
// 6. ffmpeg: three-step to avoid OOM from single-pass concat+palettegen.
// Step A: concat VHS gif + browser webm → intermediate webm.
// Step B: generate global palette from intermediate.
// Step C: palette → final GIF.
const comboOut = join(OUT_DIR, 'demo-combo.gif');
const comboIntermediate = '/tmp/nyx-combo-intermediate.mp4';
const comboPalette = '/tmp/nyx-combo-palette.png';
console.error('[combo] step A: concat → intermediate webm');
execFileSync('ffmpeg', [
'-y',
'-ignore_loop', '1', '-r', '15', '-i', cliGifPath,
'-ss', '1.0', '-r', '15', '-i', webmPath,
'-filter_complex',
'[0:v]scale=1600:992:flags=lanczos,fps=15[cli];' +
'[1:v]scale=1600:992:flags=lanczos,fps=15[bro];' +
'[cli][bro]concat=n=2:v=1:a=0[out]',
'-map', '[out]',
'-c:v', 'libx264', '-crf', '28', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
comboIntermediate,
], { stdio: 'inherit' });
console.error('[combo] step B: generate palette');
execFileSync('ffmpeg', [
'-y', '-i', comboIntermediate,
'-vf', 'fps=15,palettegen',
'-update', '1', '-frames:v', '1',
comboPalette,
], { stdio: 'inherit' });
console.error('[combo] step C: palette → gif');
execFileSync('ffmpeg', [
'-y', '-i', comboIntermediate, '-i', comboPalette,
'-lavfi', 'fps=15 [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
comboOut,
], { stdio: 'inherit' });
console.error(`[combo] wrote ${comboOut}`);
}
// Frame phase ----------------------------------------------------------------
const STILLS_PNGS = [
@ -514,14 +742,25 @@ const CLI_PNGS = [
'docs/cli-configshow.png',
];
function saveRawCopies(paths) {
for (const p of paths) {
if (!existsSync(p)) continue;
const ext = extname(p);
const rawPath = p.slice(0, p.length - ext.length) + '_raw' + ext;
copyFileSync(p, rawPath);
console.error(`[raw] ${rawPath}`);
}
}
function applyFrames(captured, { natural = false } = {}) {
// Frame only paths captured this run. Re-framing a previously-
// framed PNG would treat the framed result as the next inner
// content and produce a frame inside a frame.
const paths = captured.filter((p) => existsSync(p));
if (paths.length === 0) return;
saveRawCopies(paths);
const label = natural ? 'natural-size' : 'fixed';
console.error(`[frame] applying purple gradient frame (${label}) to ${paths.length} files`);
console.error(`[frame] applying mint-led four-corner frame (${label}) to ${paths.length} files`);
const args = natural ? ['--natural', ...paths] : paths;
execFileSync('python3', [FRAMER, ...args], { stdio: 'inherit' });
// Mirror the framed serve-overview.png to the top-level path the
@ -536,6 +775,46 @@ function applyFrames(captured, { natural = false } = {}) {
}
}
// Mirror the small subset of assets used by the nyxscan.dev landing site
// so its screenshots can't drift from the canonical ones in this repo.
// Mirrors the *_raw originals (unframed) — nyxscan.dev draws its own
// frame/hero treatment in CSS and does not want the in-repo brand frame.
// Regenerates webp variants for the PNGs (used by hero <picture>/image-set).
// Skips silently when NYXSCAN_DIR=skip, the dir is missing, or cwebp is
// not on PATH; this is a convenience step, not a hard requirement.
const NYXSCAN_MIRROR = [
['docs/serve-overview_raw.png', 'overview.png'],
['docs/serve-finding-detail_raw.png', 'finding-detail.png'],
['cli-scan_raw.gif', 'cli-scan.gif'],
['demo-combo.gif', 'demo-combo.gif'],
];
function syncNyxscanDev() {
if (NYXSCAN_DIR === 'skip') return;
if (!existsSync(NYXSCAN_DIR)) {
console.error(`[nyxscan] skip — ${NYXSCAN_DIR} does not exist`);
return;
}
let cwebpAvailable = true;
try {
execFileSync('cwebp', ['-version'], { stdio: 'ignore' });
} catch {
cwebpAvailable = false;
console.error('[nyxscan] cwebp not on PATH — copying PNGs only, webp will be stale');
}
for (const [srcRel, dstName] of NYXSCAN_MIRROR) {
const src = join(OUT_DIR, srcRel);
if (!existsSync(src)) continue;
const dst = join(NYXSCAN_DIR, dstName);
copyFileSync(src, dst);
console.error(`[nyxscan] ${srcRel} -> ${dstName}`);
if (cwebpAvailable && dstName.endsWith('.png')) {
const webp = dst.slice(0, -4) + '.webp';
execFileSync('cwebp', ['-quiet', '-q', '82', dst, '-o', webp], { stdio: 'inherit' });
console.error(`[nyxscan] ${dstName.slice(0, -4)}.webp`);
}
}
}
// Main -----------------------------------------------------------------------
async function main() {
@ -615,7 +894,11 @@ async function main() {
captureCli();
}
if (wantStills || wantCli || wantGif) {
if (wantCombo) {
await captureComboGif();
}
if (wantStills || wantCli || wantGif || wantCombo) {
// Frame phase — only frame what was captured this run so that
// already-framed PNGs from prior runs aren't framed again.
// Stills and the GIF use the fixed 1600x992 inner; CLI captures
@ -623,12 +906,15 @@ async function main() {
const fixed = [];
if (wantStills) fixed.push(...STILLS_PNGS.map((p) => join(OUT_DIR, p)));
if (wantGif) fixed.push(join(OUT_DIR, 'demo.gif'));
if (wantCli) fixed.push(CLI_GIF);
if (fixed.length) applyFrames(fixed, { natural: false });
if (wantCli) {
const cli = CLI_PNGS.map((p) => join(OUT_DIR, p));
applyFrames(cli, { natural: true });
}
syncNyxscanDev();
}
} finally {
if (browser) await browser.close();

View file

@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""Frame Nyx screenshots with the brand purple gradient.
"""Frame Nyx screenshots with the brand mint-led four-corner gradient.
Reads a list of PNG paths from argv (or all PNGs under
assets/screenshots/ if no args) and overwrites each with a framed
version: inner screenshot with rounded corners, centered on a
diagonal purple gradient (top-left #8a5bf5 → bottom-right #4d1d97).
four-corner mint-led gradient (TL #72f3d7, TR #ff6aa2,
BL #f8c56b, BR #4cc9ff).
Two framing modes:
- default inner is resampled to 1600x992, outer is 1800x1113.
@ -37,14 +38,12 @@ PAD_R = OUTER_W - INNER_W - PAD_L # 100
PAD_B = OUTER_H - INNER_H - PAD_T # 61
CORNER_RADIUS = 12
# Four-corner bilinear gradient. Sampled from the existing CLI
# screenshots so every framed asset matches: top-left is the lightest
# (Tailwind violet-500), the off-diagonal corners are violet-600, and
# bottom-right is violet-900.
GRAD_TL = (139, 92, 246) # #8b5cf6 violet-500
GRAD_TR = (124, 58, 237) # #7c3aed violet-600
GRAD_BL = (124, 58, 237) # #7c3aed violet-600
GRAD_BR = ( 76, 29, 149) # #4c1d95 violet-900
# Four-corner bilinear gradient. The primary brand accent anchors the
# frame, with distinct warm/cool corners for richer screenshot depth.
GRAD_TL = (114, 243, 215) # #72f3d7
GRAD_TR = (255, 106, 162) # #ff6aa2
GRAD_BL = (248, 197, 107) # #f8c56b
GRAD_BR = ( 76, 201, 255) # #4cc9ff
def make_gradient(w: int, h: int) -> Image.Image:
@ -135,7 +134,7 @@ def frame_one(src: Path, natural: bool = False) -> None:
def frame_gif(src: Path) -> None:
"""Frame an animated GIF in place: every frame gets the same
purple gradient frame, then the result is re-encoded as a single-
mint-cyan gradient frame, then the result is re-encoded as a single-
palette GIF. Calls ffmpeg for the final encode (Pillow's GIF
output is noticeably worse for large animations).
"""

View file

@ -377,7 +377,7 @@ fn build_taint_diag(
// Resolved sink capability bits, used by deduplication to distinguish
// sinks with different cap types on the same source line (e.g.
// `sink_sql(x); sink_shell(x);`).
let sink_caps_bits: u16 = cfg_graph[finding.sink]
let sink_caps_bits: u32 = cfg_graph[finding.sink]
.taint
.labels
.iter()
@ -385,7 +385,7 @@ fn build_taint_diag(
crate::labels::DataLabel::Sink(c) => Some(c.bits()),
_ => None,
})
.fold(0u16, |acc, b| acc | b);
.fold(0u32, |acc, b| acc | b);
// Cap-specific rule-id routing.
//
@ -508,6 +508,14 @@ fn build_taint_diag(
|| (finding.source_kind.sensitivity() >= crate::labels::Sensitivity::Sensitive
&& (flow_has_body_bind || source_is_credential_bearing)));
// Cap-specific rule routing. Auth-as-taint and data-exfil keep their
// pre-existing branches so the routing rules they encode (auth-finding
// namespace alignment; body-bind / source-sensitivity gate) stay
// exactly as before. New cap classes (LDAP / XPath / Header / Open
// redirect / SSTI / XXE / Prototype pollution) route through
// `cap_rule_meta()` so the canonical rule ids in the registry are the
// single source of truth. Legacy generic taint findings continue to
// emit `taint-unsanitised-flow`.
let diag_id = if effective_caps.contains(crate::labels::Cap::UNAUTHORIZED_ID) {
"rs.auth.missing_ownership_check.taint".to_string()
} else if is_data_exfil_rule {
@ -516,6 +524,25 @@ fn build_taint_diag(
source_point.row + 1,
source_point.column + 1
)
} else if let Some(meta) = [
crate::labels::Cap::LDAP_INJECTION,
crate::labels::Cap::XPATH_INJECTION,
crate::labels::Cap::HEADER_INJECTION,
crate::labels::Cap::OPEN_REDIRECT,
crate::labels::Cap::SSTI,
crate::labels::Cap::XXE,
crate::labels::Cap::PROTOTYPE_POLLUTION,
]
.iter()
.find(|c| effective_caps.contains(**c))
.and_then(|c| crate::labels::cap_rule_meta(*c))
{
format!(
"{} (source {}:{})",
meta.rule_id,
source_point.row + 1,
source_point.column + 1
)
} else {
format!(
"taint-unsanitised-flow (source {}:{})",
@ -576,6 +603,23 @@ fn build_taint_diag(
}
_ => crate::patterns::Severity::Medium,
}
} else if let Some(meta) = [
crate::labels::Cap::LDAP_INJECTION,
crate::labels::Cap::XPATH_INJECTION,
crate::labels::Cap::HEADER_INJECTION,
crate::labels::Cap::OPEN_REDIRECT,
crate::labels::Cap::SSTI,
crate::labels::Cap::XXE,
crate::labels::Cap::PROTOTYPE_POLLUTION,
]
.iter()
.find(|c| effective_caps.contains(**c))
.and_then(|c| crate::labels::cap_rule_meta(*c))
{
// New cap classes draw severity from the rule registry so a single
// edit to `CAP_RULE_REGISTRY` cascades through SARIF, the dashboard,
// and the integration suite without per-language source-kind nudges.
meta.severity
} else {
severity_for_source_kind(finding.source_kind)
};

View file

@ -206,8 +206,8 @@ pub fn run_auth_analysis_with_model(
// (when provided) for cross-file helpers that live in other files.
apply_helper_lifting(&mut model, lang, file_path, scan_root, global_summaries);
// Phase 1 caller-scope IPA: propagate route-handler-level auth
// checks DOWN to callee helper units within the same file. See
// Caller-scope IPA: propagate route-handler-level auth checks DOWN
// to callee helper units within the same file. See
// [`apply_caller_scope_propagation`] for the propagation rule.
apply_caller_scope_propagation(&mut model);
@ -547,8 +547,8 @@ fn apply_helper_lifting(
}
}
/// Phase 1 caller-scope IPA: propagate route-handler-level auth checks
/// DOWN to callee helper units within the same file.
/// Caller-scope IPA: propagate route-handler-level auth checks DOWN to
/// callee helper units within the same file.
///
/// `apply_helper_lifting` walks UPWARD: a helper that internally
/// proves ownership / membership / etc. has its summary lifted onto

View file

@ -1190,6 +1190,7 @@ fn clone_preserves_all_sub_structs() {
destination_uses: None,
gate_filters: Vec::new(),
is_constructor: false,
produces_null_proto: false,
},
taint: TaintMeta {
labels: {
@ -1841,9 +1842,12 @@ def outer(cmd):
assert_eq!(kwargs[1].0, "check");
}
/// Languages without keyword-argument grammar should leave `kwargs` empty.
/// JS object-literal positional args lift their `pair` children into
/// `kwargs` so consumers like xml_config's `processEntities` /
/// `resolve_entities` opt-in detector can read them without re-walking
/// the tree-sitter AST.
#[test]
fn call_node_kwargs_empty_for_javascript() {
fn call_node_kwargs_lifts_javascript_object_literal_pairs() {
let src = br"
function outer(cmd) {
child_process.exec(cmd, { shell: true });
@ -1861,9 +1865,12 @@ fn call_node_kwargs_empty_for_javascript() {
.is_some_and(|c| c.ends_with("exec"))
})
.expect("child_process.exec call node should exist");
let kwargs = &call_node.call.kwargs;
assert!(
call_node.call.kwargs.is_empty(),
"JS object-literal arg is not a keyword_argument — kwargs should stay empty"
kwargs
.iter()
.any(|(k, vs)| k == "shell" && vs.iter().any(|v| v == "true")),
"JS object-literal `{{ shell: true }}` should surface as kwarg, got {kwargs:?}"
);
}

View file

@ -7,7 +7,7 @@
//! Strictly additive: classes whose fields cannot be classified produce
//! a `DtoFields` with an empty `fields` map, the caller must decide
//! whether to use that as a "Dto with no inferred fields" or fall back
//! to the pre-Phase-6 Object/Unknown classification.
//! to the generic Object/Unknown classification.
use std::collections::{HashMap, HashSet};

Some files were not shown because too many files have changed in this diff Show more