Skip to content

0.8.0

Latest

Choose a tag to compare

@elicpeter elicpeter released this 13 Aug 14:22
· 1 commit to master since this release

The dynamic-verification release. An attack-surface map, a sandboxed dynamic verifier, a framework adapter registry that grounds both, the per-language build infrastructure that makes per-finding verification affordable at corpus scale, and the first real-corpus acceptance gates.

Alongside it, a static-engine accuracy and throughput campaign. Measured against the previous engine on an identical 669-case corpus, false positives drop from 43 to 0 and false negatives from 20 to 1 (rule-level F1 0.906 to 0.998), while a full scan of a 492-file Go service runs 1.35x faster and reports more findings than before.

The attack-surface map and chain composer turn the flat finding list into a route-to-sink graph. The dynamic verifier re-runs every Medium-or-higher finding against a payload corpus and stamps a Confirmed / PartiallyConfirmed / NotConfirmed / Inconclusive / Unsupported verdict on each. The adapter registry (130+ entries across 8 languages) covers HTTP, message-broker, scheduled-job, GraphQL, WebSocket, middleware, and migration entry points. Per-language build pools and copy-on-write workdirs hold the with-verify wall-clock to within 1.5x of a static-only scan.

Attack-surface map

  • nyx surface subcommand. Prints the project's entry points, datastores, external services, and dangerous local sinks as text, JSON, Graphviz dot, or rendered SVG. Loads the persisted SurfaceMap from the most recent indexed scan when available, or rebuilds inline from source. --build forces a full pass-1 + call-graph walk so DataStore / ExternalService / DangerousLocal nodes populate on an unscanned project.
  • Surface page in nyx serve. New SurfacePage renders the same graph in the browser UI, with ELK layout, sidebar navigation, and a wide-canvas SVG viewer. Persists alongside the index so the frontend reloads without a rescan.
  • Chain findings. ChainFinding records connect a route entry point to a downstream sink via the call graph + surface map. The composer scores (impact × evidence) per chain, queues the top-N for composite reverification, and wires the result into findings.json / SARIF / the dashboard. Chains rank above isolated findings.
  • Per-finding exposure. A finding reachable from an externally-facing route now carries the worst-case route that drives it, surfaced as an Exposure record (route, method, framework, auth state, and whether the reach is direct or transitive through the call graph). Findings with no reaching entry point, and all findings when the project has no detected entry points, carry no record, so an absent Exposure means "not connected", not "safe". Unauthenticated routes win over auth-gated ones and direct file matches win over transitive ones. The annotation shows up as an Exposure: evidence line in console output and on findings.json, SARIF properties.exposure, and the server finding view. Ranking adds a bonus for it, so a finding reachable from an unauthenticated route sorts above an otherwise-equal internal one.
  • Entry-point risk scoring. nyx surface opens with a risk-sorted "Top risk entry-points" banner and tags each route with a low / medium / high / critical tier. The score is explainable: the worst reachable sink class dominates, writing a store outranks reading it, talking to an external service and mutating HTTP methods add, and missing auth multiplies the whole exposure. The /api/surface response carries the same entry_risks array so the browser UI renders the ranking without re-deriving it.
  • Function-level reachability with typed edges. Reachability now matches a destination to an entry point when the owning function is on the call-graph frontier, not merely when they share a file, so two unrelated handlers in one file no longer both claim a co-located eval(). Edges are typed by destination: ReadsFrom / WritesTo for a datastore (split by the access direction inferred from the call verb, so a route that writes SQL reads differently from one that only queries), TalksTo for an external service, Reaches for a dangerous local sink. The pass falls back to the same-file heuristic when the handler seed cannot be resolved in the call graph, or when a destination loaded from an older persisted map predates the owning-function field.
  • Richer surface nodes. DangerousLocal carries a decoded sink-class label (code-exec, deserialize, ssti, ...) and a real sink span instead of a raw cap bitfield at line 0. DataStore and ExternalService carry the qualified name of the owning function. The dangerous-local sink set widened from four classes to ten, adding LDAP injection, XPath injection, header injection, open redirect, XXE, and prototype pollution, and datastore / external detection gained cap-driven fallbacks (SQL_QUERY / FILE_IO and SSRF / DATA_EXFIL) so a custom DAO wrapper or proxy helper still surfaces when no named driver matched.
  • Entry-point and auth recall. Handlers the framework probes miss but pass-1 already tagged as entry points are synthesised into the surface map so the entry set is a superset of what the taint engine treats as adversary-driven. auth_required upgrades when a handler's own body calls a known auth guard, complementing the router-level decorator / annotation / middleware detection.
  • Coverage telemetry. A fresh nyx surface build prints a coverage line (files seen, files in a supported language, files parsed, files with routes, plus unparsed / unreadable counts) so a small map can be told apart from "the probes did not understand this project". A loaded persisted map reports node and edge counts and points at --build for a source rebuild.

Framework adapter registry

src/dynamic/framework/ ships a FrameworkAdapter trait with concrete adapters across 8 languages (116 entries today, growing per release). Each adapter binds a route / handler / consumer pattern to a FrameworkBinding so the surface map and dynamic verifier can locate entry points without re-walking the AST.

  • HTTP routers. Flask, Django, FastAPI, Starlette (Python); Express, Koa, NestJS, Fastify (JS/TS); Spring, Quarkus, Micronaut, Jakarta Servlet (Java); Gin, Echo, Fiber, Chi (Go); Axum, Actix, Rocket, Warp (Rust); Rails, Sinatra, Hanami (Ruby); Laravel, Symfony, CodeIgniter (PHP).
  • New EntryKind variants. ClassMethod, MessageHandler, ScheduledJob, GraphQLResolver, WebSocket, Middleware, Migration join the existing RouteHandler / Function set so the surface map shows non-HTTP entry surfaces.
  • Message broker handlers. Kafka, AWS SQS, Google Pub/Sub, NATS, and RabbitMQ consumers across Python, Node, Java, and Go.
  • Scheduled jobs. Celery (Python), Sidekiq (Ruby), Quartz (Java), plain cron expression recognition.
  • GraphQL resolvers. Apollo, Relay, gqlgen, Juniper, Graphene.
  • WebSocket handlers. ws, Socket.IO, ActionCable, Django Channels.
  • Middleware + migrations. Express, Laravel, Spring, Django, Rails middleware; Django, Flask, Laravel, Rails, Prisma, Sequelize migration scripts.
  • Sanitizer-aware adapter strengthening. Every XXE, header-injection, open-redirect, SSTI, LDAP, XPath, deserialization, crypto, and data-exfiltration adapter rejects bindings when the surrounding source visibly hardens the parser (disallow-doctype-decl, resolve_entities=False, libxml_disable_entity_loader), routes the value through a known encoder (LdapEncoder.filterEncode, escape_filter_chars, ldap_escape), swaps a weak primitive for a CSPRNG (secrets.token_bytes, crypto.randomBytes, SecureRandom), or validates the destination host through an allowlist. Cuts adapter FPs without losing the genuinely dangerous calls.

Dynamic verification

  • nyx scan --verify. Every finding with Confidence >= Medium is re-executed inside a sandboxed harness against a curated payload corpus. The verdict (Confirmed / NotConfirmed / Inconclusive / Unsupported) lands on Evidence.dynamic_verdict and shows up in console output, JSON, SARIF, and the dashboard via a new VerdictBadge component on the finding detail page.
  • Backends. In-process on Linux with Standard / Strict hardening (namespace unshare, chroot, RLIMIT cap, seccomp filter), in-process on macOS via sandbox-exec with a profile-per-policy wrap, Docker with a published image-builder catalogue, and a Firecracker trait stub for future microVM execution. The Docker backend ships native binary support for Rust and Go so harnesses no longer need to drag a toolchain into every image.
  • Language coverage. Per-language harness emitters for Python, JS/TS, Go, Java, PHP, Ruby, Rust, C, and C++. Stub harness intercepts SQL, HTTP, Redis, and filesystem boundaries so the verdict reflects the sink, not the network. The JSON_PARSE, UNAUTHORIZED_ID, and DATA_EXFIL cap dispatchers are wired into every emitter that ships these caps (Python, JS, TS, Go, Java, PHP, Ruby, Rust), so the verdict pipeline closes the loop on each cap end-to-end rather than per-language piecemeal.
  • Abstract-interpretation and symex sanitizer suppression. Symbolic execution and the interval/string abstract domain are now consulted at verdict time, so a payload that the static engine would call dangerous but symex can prove never reaches the sink lands as NotConfirmed.
  • Guard-aware verdicts. When a known input-validation or output-sanitization middleware sits in front of a Confirmed sink (Spring @PreAuthorize, Express helmet, Nest @UseGuards, Django @permission_classes, and the per-language registry in src/dynamic/framework/auth_markers.rs), the verdict demotes to ConfirmedWithKnownGuard and the guard names land on differential.known_guards. Authentication-only filters do not trigger the demotion since they do not mitigate injection.
  • Repro bundles. Every verified finding writes a hermetic bundle to ~/.cache/nyx/dynamic/repro/<spec_hash>/ with reproduce.sh, expected/{verdict.json,outcome.json,trace.jsonl}, and a docker_pull.sh when the toolchain is pinned in tools/image-builder/images.toml. --verbose flushes the per-step VerifyTrace to stderr for live triage.
  • Real-engine harness paths. LDAP injection routes through an embedded LDAPv3 BER server, exercised from Java via JNDI InitialDirContext and from Python and PHP via pure-stdlib BER clients. XPath injection runs against the live parser in each language: Java javax.xml.xpath, PHP DOMXPath, JS xpath npm, Python lxml. Cap::CRYPTO lands a WeakKey probe across Python, Go, Java, PHP, and Rust that flags sub-2^16 keys produced by non-CSPRNG sources. A new HeaderSmuggledInWire oracle predicate catches CRLF smuggling on hand-rolled raw-socket HTTP servers (Python http.server, Node net, Rust std::net::TcpListener) where framework-level CRLF strip cannot intervene.
  • Differential rule v2 and partial confirmations. A finding confirms when any vulnerable payload in the set fires and every paired benign control stays clean, replacing the strict pair-wise rule so a single missing control no longer downgrades a confirmable finding. A new PartiallyConfirmed verdict marks findings where the sink is reached but the exploit chain does not complete (no marker written, no callback observed), so engine work can ratchet without the tool overstating what it proved.
  • Spec derivation v2. Every derivation strategy now runs and is scored on flow-step depth, framework binding, cross-file source resolution via GlobalSummaries, and payload availability; the highest-scoring candidate wins and the runner-up ranking lands in the trace so engine gaps stay visible. Cross-file seeding walks the call graph (max depth 5) until a Source step or framework binding is found. New EntryKind adapters auto-recover the entry surface from framework decorators and annotations.

Performance

  • Per-language build pools. A warm javac daemon compiles batched harness sources in one long-lived JVM (Track O headline, Phase 22); Node, PHP, Ruby, Go, Rust, C, and C++ reuse shared module / package / object caches; Python layers a read-only venv per requirements_hash with a warmed bytecode cache. Target per-finding harness build: P50 ≤ 200ms hot, ≤ 1.5s cold. Pools self-skip when a toolchain is absent so toolchain-less CI rows stay green.
  • Copy-on-write workdirs. Per-finding workdir setup uses clonefile on macOS and reflink / copy_file_range on Linux instead of copying every harness file, cutting setup cost to single-digit milliseconds.
  • Cap-routed concurrency lanes. The verifier worker pool splits into per-cap lanes (SSRF: 8, DESERIALIZE: 2, CRYPTO: 1, and so on) so a slow harness for one cap cannot head-of-line block fast ones.
  • Ship-gate budgets. Gate 3 holds the with-verify / static-only wall-clock ratio at ≤ 1.5x on benches/fixtures/; Gate 6 holds the Java OWASP Benchmark --verify run at ≤ 15 min on CI / ≤ 10 min on the dev reference machine.

Determinism, policy, telemetry

  • YAML policy deny list. src/policy.rs is consulted before harness build. Network egress, filesystem writes outside the sandbox root, and process spawns can be denied per-rule; deny decisions land in the trace, redacted via the shared scrubber.
  • Seeded RNG. dynamic::rand::SpecRng is seeded from each HarnessSpec hash so two runs of the same spec produce identical payloads. scripts/check_no_unseeded_rand.sh audits the tree for unseeded rand usage on every CI run.
  • VerifyTrace observability. Every per-step decision (probe selection, payload mutation, oracle check, deny verdict) writes to the trace stream and the repro bundle.
  • Schema-versioned telemetry. events.jsonl carries schema_version, nyx_version, corpus_version, kind, and ts on every envelope. PII and secret scrubbing runs on every persisted artefact via src/utils/redact.rs.
  • NYX_NO_TELEMETRY=1 disables event persistence outright.

CVE corpus and ground truth

  • New Cap corpora. Vulnerable + patched fixtures landed for the seven new cap classes (LDAP injection, XPath injection, header injection, open redirect, SSTI, XXE, prototype pollution) plus deserialization, crypto, JSON parsing, unauthorized-id, and data exfiltration. Every cap now carries at least one positive / negative / adversarial / unsupported fixture quad per supported language.
  • OWASP Benchmark v1.2 importer. tests/eval_corpus/owasp_gt_convert.py converts the OWASP Java Benchmark expected-results manifest into Nyx ground truth and lands a 16k-line owasp_benchmark_v1.2.json for evaluation.
  • NIST SARD importer. tests/eval_corpus/sard_gt_convert.py converts SARD test cases into the same format so cross-dataset recall numbers stay comparable.
  • Evaluation corpus tooling. tests/eval_corpus/run_full.sh runs the Nyx benchmark, OWASP Benchmark, and NIST SARD evaluation sets and writes tests/eval_corpus/results.json. tests/eval_corpus/report.py and tabulate.py produce the per-cap and per-language summary used to track coverage and accuracy.
  • Real-corpus acceptance gates. scripts/m7_ship_gate.sh adds Gate 6 (Java OWASP Benchmark v1.2), Gate 7 (NodeGoat + Juice Shop), and Gate 8 (RailsGoat, DVWA, DVPWA, gosec, RustSec). Each row enforces the per-(cap, lang) budget in tests/eval_corpus/budget.toml and publishes per-cap precision / recall / confirmed-rate against a committed ground truth. The corpora are not vendored; each row self-skips unless its NYX_<NAME>_CORPUS points at a checkout.
  • Per-spec cryptographic canary. Every oracle marker is now derived from BLAKE3(spec_hash || run_nonce) rather than a fixed literal, so markers are unique per finding, collision-resistant against ambient harness output, and never leak to the host. A compile-time audit rejects any new ad-hoc canary.

Engine

  • DB fast-fail preflight. Indexer::init reads the first 16 bytes of any candidate SQLite file and rejects anything without the standard SQLite format 3\0 magic. Stops a misnamed JSON / text file from corrupting the index path with a SQLite error halfway through migration.
  • Symbolic-execution coverage. Symex now recognises a wider set of string operations (substr, replace, to_lower, to_upper, trim, strlen) per the value/transfer pipeline, and the abstract-interpretation framework reasons about interval and prefix/suffix string facts during the dynamic verdict pass.

Fixed (engine correctness)

  • CFG construction. Python if/elif/elif/else chains no longer drop every alternative past the first, so a sink in a second elif or a trailing else is analysed (same fix covers PHP else_if). C-style for (init; cond; incr) loops now lower the initializer and increment, so taint introduced in the loop header (for (cmd = getenv(...); ...)) reaches the body. A switch default is no longer unconditionally hoisted to the chain tail, preserving fall-through order in C/C++/JS/TS/PHP/Java. Source/sink calls inside short-circuit && / || operands of an if / while condition are now classified instead of dropped.
  • SSA lowering of exception handlers. Catch blocks with internal control flow (an if / loop / nested try inside the handler) no longer lose every instruction past the catch entry: the orphan subtree is renamed through a virtual-root dominator tree, so sinks reached only inside a catch are seen. Catch-side variable reads now resolve to the most entry-dominating reaching definition (the pre-try value) rather than a post-join reassignment. A genuine positional argument equal to a chained-call receiver root (a.b.m(p, a)) is preserved instead of being stripped as the implicit chain root.
  • Taint soundness. Sink suppression now gates on validated_must (validated on every path) instead of validated_may (any path), closing a false-negative where a single validated branch silenced a sink. is_noreturn_call no longer matches receiver-qualified .exit() / .abort() / method calls, so transaction.abort() stops wiping taint state. The SSRF same-origin check rejects protocol-relative //host prefixes (an open-redirect / SSRF bypass that a bare /-prefix check accepted). Inline-return taint unions the derived and parameter-passthrough channels for mixed-return helpers (if (c) return src(); return x;). The inline-analysis cache is keyed to exclude callback-bound arguments, so a function-valued argument no longer poisons sibling call sites that pass a different callback.
  • Taint precision. String.valueOf(String/Object) is no longer tagged a safe-string producer (it is an identity passthrough, so String.valueOf(req.getParameter(...)) was silently suppressed). Cross-parameter sanitizers no longer bleed onto sibling arguments (f(a, b){ return a + escape(b) } sanitises only b), and a cross-file sanitizer resolved through the coarse summary tier still applies its strip. Relative-URL and host-allowlist cap clearing is alias-aware. Substring-rejection and indexOf() === -1 idioms are no longer misread as allowlist validation, and dotted multi-argument validators no longer over-validate unrelated targets.
  • Interprocedural resolution. SCC / topo file batching and reachability key files by their package-qualified namespace, matching the call-graph nodes and SSA summary tier so cross-package callers resolve. Directly self-recursive functions now get SCC fixed-point treatment. Call resolution tolerates under-application (a call supplying fewer arguments than a callee with default / optional parameters) while still degrading to Ambiguous rather than a wrong pick. A failed SCC iteration no longer overwrites a file's cached diagnostics with an empty set. JS/TS module resolution appends extensions to dotted specifiers (./user.serviceuser.service.ts) and swaps a .js import to a .ts file (NodeNext / ESM).
  • JS/TS two-level solve. Pass-2 top-level (global) taint now reaches nested closures two or more scopes deep, and the pass-2 dirty-skip no longer drops a nested body that transitively consumes a changed global through a parent-local.
  • Scan pipeline and index. replace_all_for_file deletes stale SSA summary / body rows unconditionally, so an incremental rescan cannot leave orphaned rows. Cached findings recompute their category instead of being stamped Security, so structural warnings keep their real class. The indexed build persists auth summaries and cross-package imports, logs-and-skips an unreadable file instead of aborting the whole build, and keys FuncSummary entries to match the SSA tier so an indexed scan and a full scan agree.
  • Language coverage (recall). KINDS maps were completed so previously-dropped bodies are walked: Java interface / enum / record / synchronized blocks, Rust inline mod { ... } items, Go labeled-statement bodies, and Ruby lambda / brace-block bodies. Go variadic and Python *args / **kwargs parameters are seeded with correct arity. C/C++ scanf / fscanf / sscanf / read register their output buffers as taint sources. TypeScript gated sinks dropped from the JS mirror were restored (_.template, http.get / https.get, setValue / dotProp.set / jp.set). The weak-hash and HTTP-URL AST patterns match single- and double-quoted string literals across JS, TS, and Ruby.
  • Symbolic execution. Interprocedural parameter seeding fixed an off-by-one for method calls and now seeds the receiver / self parameter; the cross-file depth guard increments on descent; and a path cut short by the global step budget records Inconclusive instead of Confirmed.
  • Abstract interpretation and pointer analysis. Interval division handles the i64::MIN / -1 overflow (degrading to unbounded instead of a falsely-narrow range) and multiplication computes overflow in i128. AbstractState::leq checks entries present only in the other state, restoring a sound partial order. The pointer fixpoint re-projects container-element field reads after the receiver's points-to set converges.
  • CFG-level analyses. Error-fallthrough termination stops at the if join point; a guard's constant-operand test refuses a Source-labelled call result; guard / sanitizer matchers require a leaf-name boundary (so invalidate no longer matches the validate guard and unquote no longer matches quote); resource ownership-transfer requires a real ->field = assignment rather than any -> in a span; post-dominators are computed once per resource pass; and the web-entrypoint heuristic confirms web parameters against the candidate handler only, so an unrelated req parameter elsewhere in the file no longer promotes batch / CLI functions to web entry points.

Cross-file authorization

  • Caller-scope authorization lifting. A private helper that is never itself a route handler now inherits route-level authorization from the units that call it. Pass 1 records one caller-scope edge per (caller, callee) pair; pass 2 appends synthetic route-level checks to a callee only when every observed caller is authorized, so one unauthorized path anywhere refuses the lift. Removes the dominant missing-ownership-check false positive on layered services/ code without weakening the check itself.
  • Cross-file authorization on the indexed scan path. Caller-scope edges and FastAPI router facts are persisted per file and reloaded in pass 2, so the lift applies under --index auto (the CLI default) and not only under --index off. Both fact sets previously existed in memory only, which meant a default scan of a layered project reported the ownership false positives the lift is designed to remove: on the cross-file auth fixtures, --index auto returned up to twice the findings of --index off. The loader replays persisted edges through the same fold the non-indexed path uses, and the accumulator is rebuilt from raw per-file edges on every scan rather than stored folded, because all_authorized is a conjunction and a stored conjunction cannot be corrected when a contributing file is edited or deleted. Indexed-vs-non-indexed parity for these fixtures is now covered by cold and warm cases in tests/indexed_parity_tests.rs. Requires a one-time re-index: the cache schema version moves to 5.
  • Framework fact suppliers. Three extractors feed the mechanism without putting framework knowledge in its core: gitea-style web.Router closure groups (ownership middleware is recognised structurally, by a permission-field read paired with an error emit, rather than by a name allowlist), graphene Meta.permissions mutation classes, and FastAPI include_router, which is now a cycle-guarded transitive graph instead of a single-hop lookup.

Static engine precision and recall

  • Divergent guards. A guard whose failing branch aborts is dominating again. LangAnalysisRules::terminators was populated only from user config, so out of the box no language had any never-returning call at all and the aborting branch kept a fall-through edge into the join, where the validated_must intersection erased the guard's narrowing. PHP die / exit, C/C++ exit / abort / err, Rust panic! / unreachable! / process::exit, Python sys.exit, Go os.Exit / log.Fatal, Java System.exit, JS process.exit, and Ruby exit / abort are now built-in terminators, with user config unioned on top. PHP's exit_statement is additionally mapped as a control-flow sink, since exit parses as its own node rather than as a call. Restores suppression of every "validate, else abort" shape, including the dash-prefix hostname gate in the patched CVE-2017-1000117 fixture.
  • Rust match guards. let x = match v { g if <guard> => …, _ => return } no longer reports x as unvalidated. When every arm other than the guarded one diverges, the guard-false gate is dropped from the statement frontier instead of being handed to the join as a predecessor on which the binding was never validated.
  • execv* executable path. The execv / execve / execvp / execvpe gates listed only the argv vector as a payload position, on the reasoning that argument 0 is not shell-parsed. There is no shell in execv*, so no metacharacter is needed: attacker control of argument 0 is direct arbitrary-program execution. execvp(getenv("PROG_PATH"), NULL) was silent in C and C++ and now reports. The existing argv-array refinement that keeps the CVE-2017-1000117 env-config shape (execvp(args[0], args)) quiet now resolves an element read back to its container, so it still applies with argument 0 in scope.
  • Structural and taint engines agree on what a source is. The structural pass treated every Source-labelled node as non-constant without mirroring the taint engine's network-fetch exemption, so file_get_contents("https://api.example.com/health") followed by echo reported an unguarded sink on data that provably came from a compile-time-pinned endpoint. Both engines now consult one predicate.
  • String.valueOf(x.length()). Restored, in argument-aware form, the confinement that a blanket String.valueOf safe-string arm used to provide before it was removed as unsound. The zero-argument numeric-length accessors are classified instead of the wrapper, so String.valueOf(rendered.length()) is confined while String.valueOf(userInput) still flows.
  • TypeScript typed parameters. An id-shaped parameter carrying any type annotation was dropped from unit.params, and since TypeScript annotates essentially every parameter, single-parameter helpers ended up with no user-input evidence at all and could never report a missing ownership check. The drop is now gated on the annotation naming a payload-incompatible primitive (number / bigint / boolean), matching the Go arm, so targetUserId: string is user input again while userId: number stays a caller-passed scope key.
  • Must-leaks are security findings. state-resource-leak is emitted only when a resource is acquired on every path and reaches no release call, out-parameter, or field escape; anything weaker is already downgraded to state-resource-leak-possible at the emit site. It is now categorised Security (CWE-401 / CWE-404 resource exhaustion) rather than Reliability. The may-analyses (state-resource-leak-possible, cfg-resource-leak, cfg-error-fallthrough) stay Reliability. Because a leak fixture's only finding is a leak, the blanket demotion had removed must-leaks from the security stream entirely: the rule fired, but every consumer filtering on Security saw nothing.
  • Interprocedural confinement post-conditions. Function summaries carry five new proved properties about a callee: a boolean prefix-check the caller branches on, a throw-if-false assert guard, a Result rejection guard, a same-origin URL normaliser, and a strncmp-confined return. Each closes a false positive on the patched form of a real CVE whose fix introduced a custom confinement helper that name matching missed. Suppressions are capability-scoped, so a confined value reaching a SQL or command sink still fires.
  • Validation-polarity fix. A negative-polarity validator (isInvalidUrl, is_not_valid) marked the reject branch as validated, exactly inverting the intended narrowing. Both branches now resolve correctly.
  • Resource ownership. Leak, double-close, and use-after-close tracking moved from flat per-acquire tracking to an ownership model: a JDBC Statement whose Connection is body-local no longer reports, connections borrowed from a managed session (Hibernate Session, EntityManager, Liquibase Database) are distinguished from owned ones, and C/C++ struct-field handles are attributed to the containing object with an escape-analysis carve-out for locals that die in the same body.
  • Go multi-assign definition attribution. f, err := os.Open(p) bound the handle to err rather than f. The first identifier is now the primary definition. Removes a false leak on err, recovers a real missed leak on the handle, and recovers taint through val, ok := os.LookupEnv().
  • Explicit sink payload positions. Sinks declare which argument positions carry the payload instead of implicitly treating all of them as payload (Rust fs::* / File::*, JS/TS raw-SQL builders, Go GORM query methods), and promisify aliases inherit the wrapped callee's restriction. The SSA sink scan is restricted to identifiers syntactically inside the inner sink sub-expression, so a tainted sibling field in an aggregate literal no longer implicates a constant-path File::open.
  • Multi-capability sink de-masking. A helper parameter consumed at two different sink classes now emits one finding per class instead of collapsing into a single conflated rule.
  • Language coverage. C gains twelve out-parameter propagations (strcpy, strlcat, snprintf, memcpy, realpath families) so path assembly through destination buffers keeps taint. Java gains Apache HttpClient request constructors as SSRF sinks; PHP gains Symfony HttpFoundation request accessors as sources; Go gains GORM query-builder SQL sinks; Ruby gains raw pg driver sinks with exec_params correctly excluded, and models operator_assignment as an assignment. Rust resolves let x: T annotations and parse::<T> turbofish, settling the FromStr ambiguity so let p: PathBuf = s.parse()? fires while let port: u16 = s.parse()? stays suppressed.
  • Corpus. Ground truth grows from 563 to 674 cases with no existing expectation modified or removed, including 18 new real-CVE vulnerable / patched pairs across all ten languages. Two cases that shared an id with an unrelated fixture were renamed (rs-safe-009 on safe_match_guard.rs to rs-safe-013, py-auth-realrepo-007 on vuln_fastapi_route_no_dependencies.py to py-auth-realrepo-027) so per-case results can be keyed on the id alone. Regression floors tighten from P 0.897 / R 0.944 / F1 0.920 to 0.980 across the board: the old margins were calibrated against a 316-case corpus and were wide enough to hide a 13-case drift.
  • Hoisted-sink provenance. The search for a callback-nested sink no longer descends a call's callee / receiver side, so a chained call cannot inherit provenance from a sink nested in its receiver's callback. In http.get(target, cb).on('error', cb2) the .on node was recording res.send('ok') from inside the http.get receiver's callback, and that unrelated 'ok' literal then read as a constant payload and suppressed a real SSRF finding.

Static scan throughput

Full-scan wall clock on a 492-file Go service drops from 36.2s to 26.9s (1.35x) while reporting more findings, not fewer. Each optimization ships an equivalence argument and most an equivalence test; several carry a NYX_DISABLE_* toggle so the previous path can be measured from the same binary.

  • Single-pass AST queries. Per-rule tree-sitter queries are concatenated into one multi-pattern query, so classification walks the tree once instead of once per rule.
  • O(1) node text. A file's UTF-8 is validated once per parse and node text is taken as a boundary slice, replacing a per-node revalidation proportional to the node's byte range.
  • Cheaper hashing and lookup. FuncKey caches a hash of its identity, turning the engine's most-hashed type from O(len) to O(1) per lookup; label matchers gained a last-byte dispatch index replacing a linear suffix scan; SSA lowering and the symbol interner moved to a faster hash; the CFG node map became a dense positional vector.
  • Taint worklist. Path environments and abstract state are shared copy-on-write instead of deep-cloned per worklist step, branch-condition classification is memoized, per-call confinement checks are hoisted out of bodies with no confiner summary, and a per-file function-name index replaces four linear scans per callee resolution.
  • Dense constant-propagation results. Constant-propagation results moved from a hash map to a dense value-indexed table, removing a per-value hashed insert per body and making every consumer lookup an array index. The persisted format is unchanged.
  • Fruitless-walk elision. Auth extraction skips whole-subtree walks that cannot produce a result for the file's language or framework, cutting the isolated Go auth pass by roughly a quarter.

CLI

  • nyx scan --verify (enabled by default in standard builds) and --backend {auto,process,docker} select the dynamic-verification harness. --no-verify skips verification for a single run without changing config.
  • nyx scan --harden {standard,strict} picks the process-backend hardening profile. standard is no-new-privs plus a memory rlimit on Linux. strict layers namespace unshare, chroot to the workdir, and a default-deny seccomp filter on Linux, or wraps the harness with sandbox-exec on macOS.
  • Patch-validation CI mode. --baseline FILE reads a previous scan's JSON (or a stripped .nyx/baseline.json written by --baseline-write) and diffs it against the current scan on stable_hash, emitting New / Resolved / FlippedConfirmed / FlippedNotConfirmed transitions. --gate {no-new-confirmed,resolve-all-confirmed} exits non-zero when the diff violates the policy so CI fails the build instead of merging an unreviewed regression. The stripped baseline carries only stable_hash, dynamic_verdict, severity, path, and rule_id, so persisting it between scans does not leak source.
  • Repository triage in CI. nyx scan now reads the same .nyx/triage.json file written by nyx serve. Terminal triage states (false_positive, accepted_risk, suppressed, fixed) are hidden from CLI output and excluded from --fail-on by default, while --show-suppressed includes them with triage_state / triage_note metadata for JSON, SARIF, and console output.
  • nyx scan --verify-all-confidence drops the Medium cutoff and re-verifies everything.
  • nyx scan --unsafe-sandbox disables hardening (development only, never for CI).
  • nyx verify-feedback <finding_id> --wrong <reason> | --right records a correction or confirmation for a finding's verdict in the local telemetry log.
  • nyx scan --explain-engine prints the effective engine configuration and exits without scanning.
  • nyx surface (described above) with --format {text,json,dot,svg} and --build.
  • nyx repro subcommand. Replays dynamic repro bundles by finding id,
    spec hash, or explicit bundle path, with --docker, --print-path, and
    --list helpers. The CLI now matches the browser UI's reproduced command
    and uses bundle manifests to bridge stable finding ids to spec-hash cache
    directories.

Frontend

  • Project target selector in nyx serve. The sidebar now remembers scan roots, lets you switch the active target, and accepts a new project path without restarting the server. /api/targets backs the selector, scans can opt into a different scan_root, and nyx scan / nyx index build register the projects they touch so nyx serve can pick them up later.
  • Surface page with ELK auto-layout and the shared node-style palette.
  • Verdict badge on finding detail, plus a dynamic-verdict section that surfaces the verdict, the payload that triggered it, and a link to the repro bundle.
  • Scan compare gains a dynamic-verdict diff column so two scans can be compared on what was confirmed versus what was downgraded.

License

  • Internal license grants documentation at LICENSE-GRANTS.md. Grant 1 covers Nyctos derived works. The repo stays GPL-3.0-or-later; the grants document scope of internal product licensing.