Skip to content

L4 DDG: a parameter's formal_in port and @entry are disjoint def-sites, so a parameter cannot reach the first call it is passed to #204

Description

@rahlk

Is your feature request related to a problem? Please describe.

At analysis_level=4, a function parameter has two disjoint def-sites in the DDG — the @formal_in:<i> port and the @entry node — with no edge between them, and only @entry reaches the argument ports (@<line>:<col>/actual_in:<i>) of the calls in that body. A consumer that starts a traversal at the formal_in port (the only vertex addressable by parameter name) therefore cannot reach the first callee the parameter is passed to.

The consequence is not a missing path — it is a proved absence of flow. A taint query that finds no path and no diagnostic is entitled to report the pair as refuted, so scrub(user_input) reads as "user_input does not reach scrub".

formal_in:<i> is the vertex a consumer can name: it is the only one carrying var, so it is what a parameter selector resolves to. Starting there:

query result correct
user_input (in handle) → raw (in scrub) 0 paths 1
raw (in scrub) → cleaned (in run_query) 2 paths

The second row shows the interprocedural machinery is otherwise intact — PY_PARAM_IN / PY_PARAM_OUT are present and carry var since 1.5.1. The break is specifically the first crossing out of the callable whose parameter you started from.

Direction of the error matters: a missing edge in a dataflow graph yields a false negative, and a consumer cannot distinguish "no path exists" from "the path is not in the graph". Any caller-parameter-to-callee-parameter taint question is answered wrongly and confidently.

Describe the solution you'd like

Not stated in the original issue.

Describe alternatives you've considered

Not stated in the original issue.

Additional context

Reproduction

app.py:

def scrub(raw):
    return "".join(ch for ch in raw if ch.isalnum())

class Handler:
    def handle(self, user_input):
        answer = scrub(user_input)
        return answer

Emitted with AnalysisOptions(..., emit=EmitTarget.NEO4J, analysis_level=4) on codeanalyzer-python 1.5.1.

user_input has two def-sites:

MATCH (a{id:'...handle(self,user_input)@formal_in:1'})-[r:PY_DDG]->(b) RETURN b.id, r.var
  --['user_input']->  ...handle(self,user_input)@35:8          # the call *statement*
  --['user_input']->  ...handle(self,user_input)@36:8

MATCH (a{id:'...handle(self,user_input)@entry'})-[r:PY_DDG]->(b) RETURN b.id, r.var
  --['user_input']->  ...handle(self,user_input)@35:8
  --['user_input']->  ...handle(self,user_input)@35:8/actual_in:0   # the argument *port*
  --['user_input']->  ...handle(self,user_input)@36:8
  --['user_input']->  ...handle(self,user_input)@36:8/actual_in:0

and there is no edge of any type between @formal_in:1 and @entry, in either direction.

The argument port's only DDG predecessors are @entry and earlier statements — never the statement that makes the call, and never the enclosing formal_in:

...handle@entry            --PY_DDG['user_input']->  ...handle@35:8/actual_in:0
...handle@entry            --PY_DDG['user_input']->  ...handle@36:8/actual_in:0
...handle@35:8             --PY_DDG['user_input']->  ...handle@36:8/actual_in:0

So @35:8/actual_in:0 — the argument of the first call — is reachable only from @entry.

Secondary observation (same emit, lower severity)

Reaching-definition edges appear to be labelled with the use's variable rather than the def's. In the four-statement body below, statements that do not define answer still carry an answer-labelled edge into its use:

...handle@35:8  --PY_DDG['answer']->  ...handle@38:8/actual_in:0   # 35 defines answer  -- correct
...handle@36:8  --PY_DDG['answer']->  ...handle@38:8/actual_in:0   # 36 defines hop
...handle@37:8  --PY_DDG['answer']->  ...handle@38:8/actual_in:0   # 37 defines note

This is over-approximation, which is the safe direction, but it inflates path counts and makes var unreliable as a def-site label. Filing it here rather than separately because it is likely the same reaching-definition pass.

CAVEATS

  • Measured on one small fixture at level 4 with the Neo4j emit. Not checked against the JSON emit, and not checked on a large project — a body with no calls before the first one may be the special case, or it may be every body.
  • Whether @entry or @formal_in:<i> is meant to be the canonical parameter def-site is the maintainer's call. Either fixes this, as long as the two are connected or the surviving one reaches both statements and argument ports.
  • The sibling analyzers were not checked. If the reaching-definition pass is shared in design, the same shape is worth measuring in codeanalyzer-java and codeanalyzer-typescript.

DEFINITION OF DONE

  • From a parameter's @formal_in:<i> port there is a DDG path to the actual_in port of every call in that body that receives it — including the first, with no preceding statement.
  • The fixture above yields a non-empty path set for user_input (in handle) → raw (in scrub).
  • A regression test asserts that reachability, in the emitter's own test suite, so a consumer's refutation cannot silently become wrong again.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions