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 surfacesubcommand. Prints the project's entry points, datastores, external services, and dangerous local sinks as text, JSON, Graphvizdot, or rendered SVG. Loads the persistedSurfaceMapfrom the most recent indexed scan when available, or rebuilds inline from source.--buildforces a full pass-1 + call-graph walk so DataStore / ExternalService / DangerousLocal nodes populate on an unscanned project.- Surface page in
nyx serve. NewSurfacePagerenders 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.
ChainFindingrecords 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 intofindings.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
Exposurerecord (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 absentExposuremeans "not connected", not "safe". Unauthenticated routes win over auth-gated ones and direct file matches win over transitive ones. The annotation shows up as anExposure:evidence line in console output and onfindings.json, SARIFproperties.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 surfaceopens with a risk-sorted "Top risk entry-points" banner and tags each route with alow/medium/high/criticaltier. 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/surfaceresponse carries the sameentry_risksarray 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/WritesTofor 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),TalksTofor an external service,Reachesfor 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.
DangerousLocalcarries a decoded sink-class label (code-exec,deserialize,ssti, ...) and a real sink span instead of a raw cap bitfield at line 0.DataStoreandExternalServicecarry 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_IOandSSRF/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_requiredupgrades when a handler's own body calls a known auth guard, complementing the router-level decorator / annotation / middleware detection. - Coverage telemetry. A fresh
nyx surfacebuild 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--buildfor 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
EntryKindvariants.ClassMethod,MessageHandler,ScheduledJob,GraphQLResolver,WebSocket,Middleware,Migrationjoin the existingRouteHandler/Functionset 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 withConfidence >= Mediumis re-executed inside a sandboxed harness against a curated payload corpus. The verdict (Confirmed/NotConfirmed/Inconclusive/Unsupported) lands onEvidence.dynamic_verdictand shows up in console output, JSON, SARIF, and the dashboard via a newVerdictBadgecomponent on the finding detail page.- Backends. In-process on Linux with
Standard/Stricthardening (namespace unshare, chroot, RLIMIT cap, seccomp filter), in-process on macOS viasandbox-execwith 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, andDATA_EXFILcap 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, Expresshelmet, Nest@UseGuards, Django@permission_classes, and the per-language registry insrc/dynamic/framework/auth_markers.rs), the verdict demotes toConfirmedWithKnownGuardand the guard names land ondifferential.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>/withreproduce.sh,expected/{verdict.json,outcome.json,trace.jsonl}, and adocker_pull.shwhen the toolchain is pinned intools/image-builder/images.toml.--verboseflushes the per-stepVerifyTraceto stderr for live triage. - Real-engine harness paths. LDAP injection routes through an embedded LDAPv3 BER server, exercised from Java via JNDI
InitialDirContextand from Python and PHP via pure-stdlib BER clients. XPath injection runs against the live parser in each language: Javajavax.xml.xpath, PHPDOMXPath, JSxpathnpm, Pythonlxml.Cap::CRYPTOlands aWeakKeyprobe across Python, Go, Java, PHP, and Rust that flags sub-2^16 keys produced by non-CSPRNG sources. A newHeaderSmuggledInWireoracle predicate catches CRLF smuggling on hand-rolled raw-socket HTTP servers (Pythonhttp.server, Nodenet, Ruststd::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
PartiallyConfirmedverdict 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 aSourcestep or framework binding is found. NewEntryKindadapters auto-recover the entry surface from framework decorators and annotations.
Performance
- Per-language build pools. A warm
javacdaemon 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 perrequirements_hashwith 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
clonefileon macOS andreflink/copy_file_rangeon 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--verifyrun at ≤ 15 min on CI / ≤ 10 min on the dev reference machine.
Determinism, policy, telemetry
- YAML policy deny list.
src/policy.rsis 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::SpecRngis seeded from eachHarnessSpechash so two runs of the same spec produce identical payloads.scripts/check_no_unseeded_rand.shaudits the tree for unseededrandusage on every CI run. VerifyTraceobservability. 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.jsonlcarriesschema_version,nyx_version,corpus_version,kind, andtson every envelope. PII and secret scrubbing runs on every persisted artefact viasrc/utils/redact.rs. NYX_NO_TELEMETRY=1disables event persistence outright.
CVE corpus and ground truth
- New
Capcorpora. 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.pyconverts the OWASP Java Benchmark expected-results manifest into Nyx ground truth and lands a 16k-lineowasp_benchmark_v1.2.jsonfor evaluation. - NIST SARD importer.
tests/eval_corpus/sard_gt_convert.pyconverts SARD test cases into the same format so cross-dataset recall numbers stay comparable. - Evaluation corpus tooling.
tests/eval_corpus/run_full.shruns the Nyx benchmark, OWASP Benchmark, and NIST SARD evaluation sets and writestests/eval_corpus/results.json.tests/eval_corpus/report.pyandtabulate.pyproduce the per-cap and per-language summary used to track coverage and accuracy. - Real-corpus acceptance gates.
scripts/m7_ship_gate.shadds 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 intests/eval_corpus/budget.tomland publishes per-cap precision / recall / confirmed-rate against a committed ground truth. The corpora are not vendored; each row self-skips unless itsNYX_<NAME>_CORPUSpoints 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::initreads the first 16 bytes of any candidate SQLite file and rejects anything without the standardSQLite format 3\0magic. 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/elsechains no longer drop every alternative past the first, so a sink in a secondelifor a trailingelseis analysed (same fix covers PHPelse_if). C-stylefor (init; cond; incr)loops now lower the initializer and increment, so taint introduced in the loop header (for (cmd = getenv(...); ...)) reaches the body. Aswitchdefaultis 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 anif/whilecondition are now classified instead of dropped. - SSA lowering of exception handlers. Catch blocks with internal control flow (an
if/ loop / nestedtryinside 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 acatchare seen. Catch-side variable reads now resolve to the most entry-dominating reaching definition (the pre-tryvalue) 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 ofvalidated_may(any path), closing a false-negative where a single validated branch silenced a sink.is_noreturn_callno longer matches receiver-qualified.exit()/.abort()/ method calls, sotransaction.abort()stops wiping taint state. The SSRF same-origin check rejects protocol-relative//hostprefixes (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, soString.valueOf(req.getParameter(...))was silently suppressed). Cross-parameter sanitizers no longer bleed onto sibling arguments (f(a, b){ return a + escape(b) }sanitises onlyb), 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 andindexOf() === -1idioms 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
Ambiguousrather 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.service→user.service.ts) and swaps a.jsimport to a.tsfile (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_filedeletes stale SSA summary / body rows unconditionally, so an incremental rescan cannot leave orphaned rows. Cached findings recompute their category instead of being stampedSecurity, 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 keysFuncSummaryentries 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 /
synchronizedblocks, Rust inlinemod { ... }items, Go labeled-statement bodies, and Ruby lambda / brace-block bodies. Go variadic and Python*args/**kwargsparameters are seeded with correct arity. C/C++scanf/fscanf/sscanf/readregister 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
Inconclusiveinstead ofConfirmed. - Abstract interpretation and pointer analysis. Interval division handles the
i64::MIN / -1overflow (degrading to unbounded instead of a falsely-narrow range) and multiplication computes overflow ini128.AbstractState::leqchecks 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
ifjoin point; a guard's constant-operand test refuses aSource-labelled call result; guard / sanitizer matchers require a leaf-name boundary (soinvalidateno longer matches thevalidateguard andunquoteno longer matchesquote); 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 unrelatedreqparameter 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 autoreturned 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, becauseall_authorizedis 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 intests/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.Routerclosure groups (ownership middleware is recognised structurally, by a permission-field read paired with an error emit, rather than by a name allowlist), grapheneMeta.permissionsmutation classes, and FastAPIinclude_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::terminatorswas 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 thevalidated_mustintersection erased the guard's narrowing. PHPdie/exit, C/C++exit/abort/err, Rustpanic!/unreachable!/process::exit, Pythonsys.exit, Goos.Exit/log.Fatal, JavaSystem.exit, JSprocess.exit, and Rubyexit/abortare now built-in terminators, with user config unioned on top. PHP'sexit_statementis additionally mapped as a control-flow sink, sinceexitparses 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 reportsxas 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. Theexecv/execve/execvp/execvpegates listed only the argv vector as a payload position, on the reasoning that argument 0 is not shell-parsed. There is no shell inexecv*, 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, sofile_get_contents("https://api.example.com/health")followed byechoreported 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 blanketString.valueOfsafe-string arm used to provide before it was removed as unsound. The zero-argument numeric-length accessors are classified instead of the wrapper, soString.valueOf(rendered.length())is confined whileString.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, sotargetUserId: stringis user input again whileuserId: numberstays a caller-passed scope key. - Must-leaks are security findings.
state-resource-leakis 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 tostate-resource-leak-possibleat the emit site. It is now categorisedSecurity(CWE-401 / CWE-404 resource exhaustion) rather thanReliability. The may-analyses (state-resource-leak-possible,cfg-resource-leak,cfg-error-fallthrough) stayReliability. 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 onSecuritysaw 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
Resultrejection guard, a same-origin URL normaliser, and astrncmp-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
StatementwhoseConnectionis body-local no longer reports, connections borrowed from a managed session (HibernateSession,EntityManager, LiquibaseDatabase) 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 toerrrather thanf. The first identifier is now the primary definition. Removes a false leak onerr, recovers a real missed leak on the handle, and recovers taint throughval, 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), andpromisifyaliases 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-pathFile::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,realpathfamilies) 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 rawpgdriver sinks withexec_paramscorrectly excluded, and modelsoperator_assignmentas an assignment. Rust resolveslet x: Tannotations andparse::<T>turbofish, settling theFromStrambiguity solet p: PathBuf = s.parse()?fires whilelet 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-009onsafe_match_guard.rstors-safe-013,py-auth-realrepo-007onvuln_fastapi_route_no_dependencies.pytopy-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.onnode was recordingres.send('ok')from inside thehttp.getreceiver'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.
FuncKeycaches 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-verifyskips verification for a single run without changing config.nyx scan --harden {standard,strict}picks the process-backend hardening profile.standardis no-new-privs plus a memory rlimit on Linux.strictlayers namespace unshare, chroot to the workdir, and a default-deny seccomp filter on Linux, or wraps the harness withsandbox-execon macOS.- Patch-validation CI mode.
--baseline FILEreads a previous scan's JSON (or a stripped.nyx/baseline.jsonwritten by--baseline-write) and diffs it against the current scan onstable_hash, emittingNew/Resolved/FlippedConfirmed/FlippedNotConfirmedtransitions.--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 onlystable_hash,dynamic_verdict,severity,path, andrule_id, so persisting it between scans does not leak source. - Repository triage in CI.
nyx scannow reads the same.nyx/triage.jsonfile written bynyx serve. Terminal triage states (false_positive,accepted_risk,suppressed,fixed) are hidden from CLI output and excluded from--fail-onby default, while--show-suppressedincludes them withtriage_state/triage_notemetadata for JSON, SARIF, and console output. nyx scan --verify-all-confidencedrops the Medium cutoff and re-verifies everything.nyx scan --unsafe-sandboxdisables hardening (development only, never for CI).nyx verify-feedback <finding_id> --wrong <reason> | --rightrecords a correction or confirmation for a finding's verdict in the local telemetry log.nyx scan --explain-engineprints the effective engine configuration and exits without scanning.nyx surface(described above) with--format {text,json,dot,svg}and--build.nyx reprosubcommand. Replays dynamic repro bundles by finding id,
spec hash, or explicit bundle path, with--docker,--print-path, and
--listhelpers. 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/targetsbacks the selector, scans can opt into a differentscan_root, andnyx scan/nyx index buildregister the projects they touch sonyx servecan 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.