#Taint analysis
Nyx tracks untrusted data from sources (where it enters the program) through assignments and calls to sinks (where it's used dangerously). Reaching a sink without passing a matching sanitizer fires a finding.
The engine is a monotone forward dataflow over a finite lattice with guaranteed termination: flow-sensitive inside a function, interprocedural across files via persisted per-function summaries.
#Rule ID
taint-unsanitised-flow (source <line>:<col>)
One rule ID, parameterized by the source location. Suppressions can target either the base ID or the full string.
#What it detects
- User input to shell execution:
req.body.cmdintochild_process.exec - User input to code evaluation:
req.query.codeintoeval - User input to SQL:
request.args.get('id')intocursor.execute(f"... {id}") - Environment variables to shell:
env::var("CMD")intoCommand::new("sh").arg("-c") - Request parameters to HTML:
req.query.nameintoinnerHTML - File contents to privileged sinks:
fs::read_to_stringintodb.execute - Any other source-to-sink flow where the sink's required capability is not stripped along the way
#What it can't detect
- Library calls without summaries. A callee with no summary (no source, binary-only dependency) counts as neither propagating nor sanitizing: conservative for sanitization, lossy for propagation.
- Deep pointer aliasing. One level works (
let y = &x; sink(*y)), but arbitrary chains of pointer arithmetic and aliased writes (*p,p->fieldin C/C++) are not tracked end-to-end. Function pointers and indirect calls resolve to no callee. - Implicit flows. Taint follows explicit data, not branching signal:
if (secret) x = 1 else x = 0does not taintx. - Globals and statics across function boundaries.
#Common false positives
| Scenario | Why | Mitigation |
|---|---|---|
| Custom sanitizer not recognised | Only built-in + configured sanitizers match | Add a custom sanitizer rule in config |
| Container holds mixed-typed items the engine cannot tell apart | A vector<int> of port numbers and a vector<string> of user input share the same store/load model |
Sanitize the values on the way in (numeric parse / explicit validator) so the values themselves carry no cap, not just the container |
| Dead branches | Path-insensitive within a function | Constraint solving catches trivially infeasible combos; path-validated findings are scored lower |
| Library wrapper re-introduces taint | Wrapper opaque, or summary marks it as propagating | Summarize the wrapper explicitly or add it as a sanitizer |
#Common false negatives
| Scenario | Why |
|---|---|
| Third-party library on the path | No summary available, callee treated opaquely |
| Globals / statics across function boundaries | Not tracked |
| Some closure captures | Closure analysis is limited. JS/TS/Ruby/Go anonymous functions passed as callbacks are analyzed as separate scopes |
| Very deep cross-file chains | Summary approximation loses precision at depth |
#Confidence signals
Higher confidence:
- Source + Sink both present in evidence with specific call locations.
source_kind: user_input(direct attacker control).path_validated: false.- No dominating guard on the path.
- Symex produced a witness string (rendered sink value visible in JSON/SARIF
evidence.symbolic.witness).
Lower confidence:
- Path-validated taint (
path_validated: true). - Source is a database read or internal file (pre-validated at insertion is common).
- Any non-informational engine note (
SsaLoweringBailed,ParseTimeout,PredicateStateWidened,PathEnvCapped,WorklistCapped, etc.). Use--require-convergedto drop over-report and bail notes in strict gates.
#Tuning
#Custom sanitizer
# nyx.local
[[analysis.languages.javascript.rules]]
matchers = ["escapeHtml", "sanitizeInput"]
kind = "sanitizer"
cap = "html_escape"
Or: nyx config add-rule --lang javascript --matcher escapeHtml --kind sanitizer --cap html_escape.
#Filter by severity or confidence
nyx scan . --severity HIGH
nyx scan . --min-confidence medium
#Skip dataflow entirely
nyx scan . --mode ast
AST-only mode gives structural pattern matches without taint.
In the browser UI, taint findings render as a numbered flow walk, one step per hop the engine took:

#Example
Rust:
use std::env;
use std::process::Command;
fn main() {
let cmd = env::var("USER_CMD").unwrap(); // source
Command::new("sh").arg("-c").arg(&cmd).output(); // sink
}
Finding:
[HIGH] taint-unsanitised-flow (source 5:15) src/main.rs:6:5
Unsanitised user input flows from env::var → Command::new
Source: env::var (5:15)
Sink: Command::new
Safe rewrite: drop the shell and pass the value as argv directly (Command::new(&cmd).output()), or validate against an allowlist before passing to the shell.
#Capabilities
Sources, sanitizers, and sinks are linked by named capabilities. Sources typically declare cap = "all" so they match every sink; a sink fires only when the remaining taint still carries the cap it requires; a sanitizer clears only the cap it names.
| Capability | Typical source | Typical sanitizer | Typical sink |
|---|---|---|---|
env_var |
env::var, getenv, process.env |
||
html_escape |
html.escape, DOMPurify.sanitize |
innerHTML, document.write |
|
shell_escape |
shlex.quote, shell_escape::escape |
system, Command::new, eval |
|
url_encode |
encodeURIComponent |
location.href, HTTP client URL arg |
|
json_parse |
JSON.parse |
||
file_io |
os.path.realpath, filepath.Clean, canonicalise + starts_with-rooted guard |
open, fs::read_to_string, send_file |
|
fmt_string |
printf(var) |
||
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 |
|
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 |
#Source sensitivity
Some detector classes need to know not just that a value is attacker-influenced but what kind it is. Each source carries a SourceKind (UserInput, Cookie, Header, EnvironmentConfig, FileSystem, Database, CaughtException, Unknown) and a derived sensitivity tier:
| Tier | Source kinds | Meaning |
|---|---|---|
Plain |
UserInput (request bodies, query strings, form fields, argv, stdin) |
Attacker-controlled but already in the attacker's hands. Echoing it back to them is not a disclosure. |
Sensitive |
Cookie, Header, EnvironmentConfig, FileSystem, Database, CaughtException, Unknown |
Operator-bound state that should not leak across boundaries. |
Secret |
(reserved for explicit credential sources) | Highest tier; treated identically to Sensitive today. |
Cap::DATA_EXFIL only fires when the contributing source is at least Sensitive. Plain user input flowing into an outbound fetch body is suppressed at finding-emission time, which is the canonical false-positive class for API gateways and telemetry forwarders that proxy req.body. SSRF and other classes are unaffected: the gate is scoped to DATA_EXFIL.
If a project legitimately classifies a request body as sensitive (say an internal forwarder where req.body carries a pre-authenticated user token), override via custom rules in nyx.conf:
# Treat the forwarder's outbound payload as already-sanitized so the
# DATA_EXFIL gate stops firing on it.
[[analysis.languages.javascript.rules]]
matchers = ["sanitizeOutbound"]
kind = "sanitizer"
cap = "data_exfil"
Or re-classify the source itself with a custom Source rule whose name matches one of the Sensitive substrings (cookie, header).
#DATA_EXFIL suppression layers
Three suppression knobs ship by default so projects can match the cap to their architecture without per-call suppressions.
#1. Forwarding-wrapper sanitizer convention
A named function whose job is to forward a payload across a known boundary is the developer's explicit decision to send the data. The default sanitizer rules treat these identifiers as Sanitizer(data_exfil) in JavaScript and TypeScript:
serializeForUpstream
forwardPayload
tracker.send
analytics.track
metrics.report
logEvent
Calls matching these names stop firing the cap. Add your own forwarding wrappers the same way:
[[analysis.languages.javascript.rules]]
matchers = ["dispatchTelemetry", "sendToBus"]
kind = "sanitizer"
cap = "data_exfil"
The rule of thumb: a function that only exists to ship a payload to a known boundary belongs in this list. A function that might leak (a generic HTTP wrapper, a logging helper that writes to an arbitrary destination) does not.
#2. Destination allowlist
Configure trusted outbound prefixes once, and the cap drops on every site whose destination argument has a static prefix beginning with one of them:
[detectors.data_exfil]
trusted_destinations = [
"https://api.internal/",
"https://telemetry.",
]
Use full origins or origin-pinned paths so a partial-host match across unrelated origins cannot occur. https://api. would also match https://api.evil.example.com/, so the entry must include the path separator (/) at the end of the host.
The match consults the abstract string domain: a literal URL is a static prefix; a template literal \https://api.internal/${id}\`` exposes the prefix https://api.internal/; a fully dynamic URL has no prefix and the cap fires as usual.
#3. Detector-class disable
Some projects forward user-bound payloads as a matter of architecture. Turn the whole detector class off when the noise is permanent:
[detectors.data_exfil]
enabled = false
enabled = false strips Cap::DATA_EXFIL from sink caps before event emission, so no taint-data-exfiltration finding reaches the report. The decision is per-project; other projects loaded by the same nyx serve instance keep their own settings.
#DATA_EXFIL sinks per language
Sinks Nyx ships with for Cap::DATA_EXFIL. The body, headers, or json payload arg fires; the URL arg routes through the SSRF gate and emits taint-unsanitised-flow instead.
| Language | Sinks | Example |
|---|---|---|
| JavaScript, TypeScript | fetch(url, {body, headers, json}) body-bind, XMLHttpRequest.prototype.send, type-qualified HttpClient.send |
fetch('/upload', {method: 'POST', body: req.cookies.session}) |
| Python | requests.post / put / patch body and json kwargs, httpx.AsyncClient().post json kwarg, aiohttp.ClientSession().post body, dict round-trip into json |
requests.post('https://api.internal/ingest', json={'k': os.environ.get('SECRET')}) |
| Java | HttpClient.send with BodyPublishers.ofString, OkHttp newCall(req).execute body chain, Apache HttpClient.execute(HttpPost), RestTemplate.postForEntity / exchange, WebClient.post().bodyValue / body |
client.send(HttpRequest.newBuilder().uri(...).POST(BodyPublishers.ofString(token)).build(), ...) |
| Go | http.Post(url, ct, body) body arg, http.PostForm form arg, (*http.Client).Do(req) after http.NewRequest, (*http.Request).Body assignment |
http.Post("https://analytics.internal/track", "text/plain", strings.NewReader(c.Value)) |
| Rust | reqwest::Client.post().body / json / form / multipart().send(), ureq::post().send_string / send_form / send_json, surf::post().body_string / body_json, hyper::Request::builder().body() |
reqwest::Client::new().post(url).form(&secret).send() |
| Ruby | Net::HTTP.post(uri, body) body arg, Net::HTTP::Post.new(uri).body=, RestClient.post / put, HTTParty.post(url, body: ...) body |
Net::HTTP.post(URI('https://analytics.internal/track'), "session=#{request.cookies[:auth]}") |
| C, C++ | curl_easy_setopt(handle, CURLOPT_POSTFIELDS, body) and CURLOPT_COPYPOSTFIELDS gated sinks (macro-arg activation), CURLOPT_POSTFIELDSIZE body-bind |
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, getenv("AUTH_TOKEN")); |
| PHP | curl_setopt($ch, CURLOPT_POSTFIELDS, $body), Guzzle\Client.post($url, ['body' => $tainted]), Symfony\HttpClient->request('POST', $url, ['body' => $tainted]) |
curl_setopt($ch, CURLOPT_POSTFIELDS, $_COOKIE['session']); |
Add project-specific sinks with nyx config add-rule --kind sink --cap data_exfil --matcher <name> or the equivalent TOML rule.
#DATA_EXFIL calibration ranges
taint-data-exfiltration is calibrated below the other taint classes on purpose.
| Source kind | Severity | Confidence ceiling |
|---|---|---|
| Cookie, environment variable | High | Medium |
| Header | Medium | Medium |
| File system, database | Medium | Medium |
| Caught exception | Medium | Low |
Path-validated flows (path_validated: true) drop one severity tier. Confidence drops to Low when the abstract or symbolic domain cannot corroborate a concrete string reaching the outbound payload, for example when the body comes from a callee with no summary.
Attack-surface score ranges:
| Finding shape | Score |
|---|---|
| High DATA_EXFIL, cookie or env source, body confirmed | around 76 |
| Medium DATA_EXFIL, header, fs, db, or caught-exception source | 40 to 45 |
| Low DATA_EXFIL, no abstract corroboration, path-validated | 18 to 25 |
For reference: High SSRF, SQLi, cmdi land at 76 to 81; Medium taint with env source lands at 45 to 50; AST-only patterns sit around 10.