Skip to main content
Version: Next

Value flow

A -[:FLOWS_TO]-> B means the value represented by A may contribute to the value represented by B. It's data flow, and the graph later taint queries traverse. It isn't name resolution (REFERS_TO), access classification (READS/WRITES), AST containment (SON) or a call edge. FLOWS_TO has no properties.

This version is deliberately small. It covers values moving through expressions, into variables and back out of them, within the code of each file. ARGUMENT_TO and RETURNS_TO extend it across statically resolved calls. Properties and control flow come later.

const input = source;
let x = input;
x = x + "!";
console.log(x);

source ──> input (decl) ──> input (use) ──> x (decl) ──> x (use, rhs) ──> x + "!" ──> x = x + "!" ──┐
^ │
└──────────────────────────────────────────────────┘
x (decl) ──> x (use, in console.log)

Flow-insensitive bindings​

A binding's declaration Identifier is the summary node for every value the binding ever holds:

  • Writes go in. The value written flows to the declaration: init → x(decl), AssignmentExpression → x(decl).
  • Reads come out. Each read occurrence gets an edge from the declaration: x(decl) → x(use). A REFERS_TO that isn't a read, such as an assignment's left side or export { x }, gets no such edge.

So every write reaches every read, wherever they are in the code. That's an over-approximation, which is the intent. It can't miss a flow that the per-file program allows, and it doesn't pretend to track order. Reaching definitions or SSA can refine it later.

It also means cycles are normal. x = x + 1 gives x(decl) → x(use) → x + 1 → x = x + 1 → x(decl). Path queries need cycle protection. Ladybug's ACYCLIC (no repeated node) or TRAIL (no repeated edge) path semantics do this:

MATCH p = (src)-[:FLOWS_TO* ACYCLIC 1..30]->(sink)

See the query cookbook.

Rules​

facts(node) runs on every node during the walk. It records flows in terms of AST nodes, and also writes. After resolution, any flow into a target Identifier is redirected to its binding's summary declaration. A flow into an unresolved target is dropped.

NodeFlows
VariableDeclaratorinit → binding, when id is an Identifier
AssignmentExpression =right → the assignment, then the assignment → binding (when left is an Identifier)
AssignmentExpression compound or logical (+=, ||=, ...)left (a read) → the assignment, right → the assignment, the assignment → binding
UpdateExpressionargument → the update, the update → binding
AssignmentPatternright (the default) → binding, when left is an Identifier
BinaryExpression, LogicalExpressionleft → node, right → node
ConditionalExpressionconsequent → node, alternate → node. Not test.
UnaryExpressionargument → node, except void and delete
TemplateLiteraleach ${} expression → node
SequenceExpressionthe last expression → node only
ReturnStatementargument → node
TSAsExpression, TSSatisfiesExpression, TSNonNullExpression, TSTypeAssertion, TSInstantiationExpressionexpression → node (they don't change the value)
a resolved readdeclaration → use

Notes on the less obvious rows:

  • An assignment is an expression. Its own value is what it assigned, so the value goes through the AssignmentExpression node on its way to the binding. That way, in y = (x = source), the inner assignment flows into x and also onwards into the outer assignment and on to y.
  • The conditional test isn't data. c ? a : b chooses between a and b, but c isn't part of the result. That's control dependence (CONTROL_DEPENDS_ON, later). c is still a READ.
  • void x is always undefined. delete x is a boolean about whether a property was removed. Neither carries x's value. typeof, !, +, - and ~ do.
  • Postfix and prefix updates. x++ returns the old value and ++x the new one, but both depend only on x's old value. So they share the same edges.
  • Sequences. In (a(), value), only value is the result. a() is evaluated for its effects.
  • Unresolved operands still flow. In location + 1, location has no declaration, but the Identifier is still a value-producing expression: location → BinaryExpression. That's where a later source rule attaches taint.

Deferred​

None of these create FLOWS_TO yet. They're READS/WRITES only, or nothing.

DeferredExampleWhyLater
Arguments into the call's valuefoo(x)The result needn't depend on any argument. foo and x are read. For a resolved call, x goes ARGUMENT_TO the param and the result comes back through RETURNS_TO.done for resolved calls
Unresolved callseval(x), obj.m(x)Opaque: no flow through them, except the builtins below.property pass, host models
Member readsobj.fooThe property's value isn't the object. No obj → obj.foo. The MemberExpression itself still flows on, for example into const y = obj.foo.READS_PROPERTY
Property writesobj.foo = vThe heap isn't modelled. v flows into the assignment but stops there.WRITES_PROPERTY, ALIASES
Destructuringconst { a } = o, [a] = arrIt's a property or element read, like obj.foo. a is written, but nothing flows into it.property pass
for-of / for-infor (x of xs)Elements and keys are element reads. x is written, with no flow.property pass
Object and array literals[x], { k: x }Aggregate contents need heap modelling.property pass
await, yield, Promises, callbacksawait pPromise and callback semantics.later
Function valuesfunction f() {}The function isn't a FLOWS_TO value flowing into f. The call graph reads declarations directly.
Implicit flowif (secret) y = 1Control dependence.CONTROL_DEPENDS_ON

Builtins​

An unresolved call to one of these builtins carries its input into its result, as FLOWS_TO input → call:

KindNamesInput
Global functionsdecodeURIComponent, decodeURI, unescape, String, Objectthe first argument
String methodsslice, substring, substr, trim, trimStart, trimEnd, toLowerCase, toUpperCase, padStart, padEnd, repeat, normalize, replace, replaceAll, toString, valueOf, at, concatthe receiver (x in x.slice(1)), plus every argument for concat

A global function counts only when its name doesn't resolve to a local binding. Methods match by name alone, since the receiver's type isn't known, so arr.slice(1) also passes arr through. That over-approximation is acceptable for taint. Encoders (encodeURIComponent, escape) are deliberately absent: their output is safe in HTML, so they act as sanitizers. A call that resolves (CALLS) never gets these edges.

Coverage​

src/flow.test.ts checks the spec's value-flow cases by exact edge list:

  • const y = x, x = y, and x = x + 1 with its cycle
  • compound and logical assignment, and update expressions
  • calls (no flow from arguments into the call)
  • the conditional without its test, the template literal, and the sequence's last expression only
  • nested assignment, and return
  • unresolved globals

It also checks that there's no flow through destructuring or for-of. It covers the TS as passthrough, the var redeclaration summary, default parameters and unary operators. Reachability is checked with FLOWS_TO* ACYCLIC paths.