Grep vs a Code Graph for Finding Bugs

Grep vs a Code Graph for Finding Bugs

Reach for grep and you are matching characters. Reach for a code graph and you are matching meaning. This post is about that gap, and why semantic code search over a real parse of the code finds security bugs that plain text search walks straight past.

grep matches text, not meaning

grep is a text tool. You give it a string or a pattern, it scans lines, and it prints the lines that match. That is exactly what you want when you know the literal thing you are looking for. A config value, a hardcoded URL, a function name you are sure of, a first look at a strange file. grep is fast, it runs everywhere, and it never needs to understand the language. For those jobs it is the right tool and nothing beats it.

The trouble starts when the question is not about a string. Security review is almost never about a string. It is about movement. Who calls this function. What value reaches this query. Can attacker input get to this sink. Those questions are about the structure of the program, and structure is the one thing raw text does not carry.

Four places plain text search quietly fails

Here are four failures you hit in real review work. None of them are exotic. They show up in ordinary code every week.

1. A rename hides a live caller

Say a helper used to be called get_user and someone renamed it to load_user. You are auditing callers of the old function because you remember it skipped an authorization check. You run:

grep -rn "get_user" .

You get a handful of comments and one stale doc string. The real caller now reads load_user(req.user_id), and grep never shows it, because the characters get_user are simply not there anymore. The dangerous call is live in the running app and invisible to your search. You did not find zero callers. You found zero matches, and you read that as safe.

2. A value arrives through an alias

Taint travels through variables. The source name and the sink almost never sit on the same line.

raw = request.args.get("path")
target = raw
full = os.path.join(BASE, target)
open(full)

You grep for request.args near open( and get nothing useful, because by the time the value reaches open it is called full, and one hop earlier it was target. The text at the sink contains none of the source text. A path traversal bug sits right there and a text search cannot connect the two ends.

3. A wrapper or indirect call hides the real target

Code rarely calls the dangerous function by its plain name at the dangerous spot.

def run(cmd):
    return subprocess.run(cmd, shell=True)

run(user_input)

Grep for subprocess.run and you find the wrapper, not the risky call site that passes user input into it. Grep for shell=True and you find the definition, but not the caller that makes it dangerous. The two facts that matter, tainted input and a shell execution, live in different functions joined by a call. Text search sees two unrelated lines.

4. The same string appears everywhere

Now the opposite problem. You search for eval( and get forty hits. Most are in tests. Some are in a comment warning people not to use it. A few are in a dead code path behind a feature flag that has been off for a year. Exactly one is a real reachable sink. grep cannot tell a live sink from a comment from dead code, because all four look identical as text. You are left reading forty lines by hand to find the one that runs.

What semantic code search does instead

A code graph is built from a real parse of the code, the same kind of parse a compiler does. Functions, calls, parameters, assignments, and the edges between them become nodes you can query. Because the graph knows what a call is and not just what it looks like, semantic code search answers the questions text cannot.

  • Who calls this? The graph follows call edges, so a renamed function still shows every live caller. This is call graph analysis, and it does not care what the string used to be.
  • What flows into this parameter? The graph tracks assignments, so it walks raw to target to full and reports that a request value reaches open. That is source to sink dataflow analysis.
  • Is this sink reachable? The graph knows which nodes sit in live code and which sit in a test or a dead branch, so it can drop the noise that buries a real finding.
  • What is the real target of this call? The graph resolves the wrapper to the function it actually reaches, so run(user_input) lines up with the shell=True execution inside it.

The structure that makes this work has a name. It is a code property graph, a parse of the program plus the call and data edges laid on top, queried as one graph.

Text search asks whether a string is present. A code graph asks whether a path exists. Security bugs live on paths, not in strings.

The rename example, run both ways

Take the rename from failure one and make it concrete. The old function was get_user, now it is load_user, and one caller still passes a raw id without an ownership check.

# search.py
def load_user(uid):
    return db.query("SELECT * FROM users WHERE id = " + uid)

# report.py
row = load_user(params["id"])

Run grep -rn "get_user" . and you get nothing. The name is gone, so the audit comes back empty and you move on. Ask a code graph “who calls load_user” and it returns report.py with the exact call site, then follows params["id"] into the raw SQL string and flags the injection. Same code, same bug. One tool reported clean because the text changed, the other found the path because the structure did not.

The public engine we build on is lachesis, a code graph over Python, TypeScript, JavaScript, and C, built from a real parse so a rename or an alias does not break the answer.

Use both, and know where each stops

This is not grep versus everything. grep is the right first move on any codebase. It is instant, it is universal, and for a literal string or a config value it is perfect. Keep using it. The point is narrow and it matters: the moment your question turns into who calls this, what flows here, is this reachable, you have left the ground where text search can answer. Those questions are about data movement, and data movement is structure, and structure needs a parse.

A good workflow uses grep to get oriented in seconds and a code graph to reason about the paths that decide whether a bug is real. For more on that split, read scanners vs research. Reasoning over a real code graph is the ground UnboundCompute is built on, an autonomous researcher that follows how an app actually moves data rather than matching the text of a payload. You can read how we think about it on our about page.

Frequently asked questions

What is the difference between grep and semantic code search?

grep matches characters. You give it a string or a pattern and it prints matching lines, with no idea what the code means. Semantic code search runs over a code graph built from a real parse of the program, so it answers questions about structure like who calls this function and what value reaches this sink, which plain text cannot see.

Why does grep miss a caller after a function is renamed?

grep only finds the text you type. If a helper was renamed from get_user to load_user, searching for get_user returns nothing even though a live caller still exists under the new name. A code graph follows call edges instead of text, so it lists every real caller no matter what the function is now called.

Can grep follow tainted input from a source to a sink?

Not reliably. Once a value passes through an alias or a variable, the text at the sink no longer contains the source name, so a text search cannot join the two ends. A code graph tracks assignments and call edges, so it can trace a request value across several hops into a dangerous function.

Is grep still useful for security work?

Yes. grep is fast, universal, and perfect for a literal string, a config value, or a first look at an unfamiliar file. It stops being enough when the question turns into who calls this, what flows here, or is this sink reachable, because those are about data movement and structure, which needs a real parse.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.