Category: Scanners vs Research

Why scanners find noise, what real testing looks like, and the case for verification.

  • 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.

  • Static Analysis False Negatives: The Bugs SAST Misses

    Static Analysis False Negatives: The Bugs SAST Misses

    A false positive wastes an hour. A false negative can cost you a breach, because the tool told you a piece of code was clean when it was not. This post is about static analysis false negatives, the real bugs a scanner stays silent on, why silence is more dangerous than noise, and what it takes to actually prove a bug is not there rather than just fail to find it.

    Why static analysis false negatives are the worse failure

    Its twin, the false positive, is loud. It shows up in your backlog, annoys a developer, and gets closed. A false negative makes no sound at all. The scan goes green, the pull request merges, and everyone moves on believing the code was checked. That green check is the danger. It replaces “we do not know” with “we are safe,” and the team ships on false confidence. If you want the noisy side of the story, we cover it in why SAST has false positives. Here we walk the quiet side: five reasons a static tool misses a real bug, each with a short example.

    Cause one: the sink is not in the tool’s list

    A static scanner only flags a dangerous call if that call is on a list it carries. Injection detection works by knowing that db.execute or os.system is a place tainted input must never land. If your dangerous call is a wrapper the tool has never seen, there is no rule to fire.

    def run_shell(cmd):
        # thin wrapper the team wrote around subprocess
        return _spawn(cmd, shell=True)
    
    def handler(request):
        host = request.args.get("host")
        run_shell("ping -c 1 " + host)

    The user controls host, it flows straight into a shell, and this is a command injection. But the sink is run_shell, an internal helper, not the built in call the scanner watches for. The tool sees a function call it has no opinion about and moves on. The bug is real and the report is empty. Every codebase grows its own wrappers, and each one the catalog does not know is a blind spot.

    Cause two: dynamic dispatch hides the real call target

    Static analysis has to connect the place a value comes from to the place it is used. When the actual function called is chosen at run time, through reflection, a function pointer, or a lookup table, the tool often cannot tell which function runs, so it never draws the edge that would carry the taint.

    ACTIONS = {"save": save_note, "export": export_note}
    
    def dispatch(request):
        name = request.args.get("action")
        data = request.args.get("data")
        ACTIONS[name](data)   # which function is this?

    If export_note writes data into a file path or a query without cleaning it, that is the bug. But the call goes through a dictionary keyed by a string, so the analysis cannot always prove which function is on the other end. To avoid a wrong guess it connects nothing, and the tainted flow into export_note is never traced. Reflection by name, virtual methods, and function pointers all produce the same broken link.

    Cause three: framework and config behavior the parser never sees

    Modern apps put a lot of behavior outside the code the parser reads line by line. Routes are wired by decorators or a config file. Input arrives through a callback the framework invokes. An ORM turns a method call into SQL somewhere the tool cannot follow.

    # the framework calls this by name from a route table
    @route("/upload/")
    def upload(user_id):
        path = STORAGE + "/" + user_id
        open(path, "wb").write(request.data)

    Here user_id comes from the URL and lands in a file path, so a value like ../../etc/cron.d/x writes outside the storage folder. That is a path traversal. But the scanner may never register that upload is an entry point at all, because the route is bound by a decorator string it does not model. If the function looks like dead code that nobody calls, its input never counts as attacker controlled, and the flaw stays invisible.

    Cause four: the value crosses a boundary the analysis does not follow

    Most static tools reason inside one process and often inside one function at a time. The moment a value leaves through a queue, a cache, a file, or a call to another service, the thread of the analysis is cut. What comes back out the other side looks brand new and untainted.

    # service A
    queue.push(request.args.get("payload"))
    
    # service B, a different file or process
    job = queue.pop()
    os.system("convert " + job)

    The user controls payload in service A. It rides a queue to service B and gets concatenated into a shell command. End to end this is a clean command injection, but no single function holds both halves. The tool analyzing service B sees job arrive from queue.pop() with no visible source, treats it as trusted, and says nothing. Values that round trip through Redis, a database column, or a call to a sibling service disappear from the taint graph the same way.

    A false positive tells you a lie you can check. A false negative tells you a lie you will only discover when someone exploits it.

    Cause five: logic and access control bugs have no sink to match

    The four causes above are all missed connections in a flow the tool would flag if it could see it. This last one is different, and it is the hardest. Some of the worst bugs have no dangerous call anywhere, nothing malformed, no pattern to grab.

    @route("/api/invoices/")
    def get_invoice(invoice_id):
        inv = Invoice.query.get(invoice_id)
        return inv.to_json()   # never checks who owns it

    A user who owns invoice 41 requests GET /api/invoices/42 and reads someone else’s invoice. The query is safe, the input is a clean integer, and the response is a normal 200. This is broken access control, one of the most common serious bugs in real apps, and pattern matching has nothing to catch on. There is no sink, no tainted string, no known bad shape. The bug is the missing check, and you cannot pattern match an absence. We go deeper on this in why SAST misses business logic.

    The coverage versus noise tradeoff, honestly

    Here is the part vendors do not say out loud. False positives and false negatives pull against each other. Tune a scanner to report every possible sink and you catch more real bugs while you drown the team in noise. Tune it to stay quiet and the backlog gets clean while the miss rate climbs. Cutting false positives too aggressively is exactly how you buy more false negatives. A tool tuned to look calm on a dashboard is often one that has been told to stay silent.

    So the goal is not a quieter tool or a louder one. It is a tool that reports fewer maybes because it can prove more. Two things move that line:

    • Reachability, done for real. Instead of matching a sink shape, follow the specific value through the specific branches and prove attacker input arrives at a real dangerous call. That both drops false positives and, done properly across wrappers and call boundaries, finds the flows a shallow scan cut short. We explain the mechanics in reachability analysis for security.
    • Reasoning about intent for the logic bugs. The invoice case is invisible to any pattern engine. Catching it means understanding what the app is for, that an invoice belongs to an owner, that a request should be checked against that owner, and then testing whether the check exists. That is reasoning, not matching.

    What this means for how you read a green scan

    Treat a clean static result as “no known pattern fired,” not “no bug here.” The wrapper it did not know, the dispatch it could not resolve, the route bound in config, the value that crossed a queue, and the access check that was never written are all still on the table after the scan goes green. A finding you can defend points at a source, a sink, and the path between them. Silence proves none of that.

    This is the gap we build for at UnboundCompute. It learns how an app is meant to work, forms ideas about where that logic breaks, and proves a finding with real evidence before reporting it, the same discipline that separates a missed bug from a caught one. More of this thinking lives under scanners vs research, and you can read how we approach it on our about page.

    Frequently asked questions

    What is a false negative in static analysis?

    A false negative is a real bug that a static scanner does not report. The scan looks clean, so the code appears checked when it was not. This is more dangerous than a false positive because it replaces honest uncertainty with false confidence, and the team ships believing the code is safe.

    Why are false negatives worse than false positives?

    A false positive is loud. It lands in your backlog, wastes a developer an hour, and gets closed. A false negative makes no sound at all. Nobody knows the bug is there until someone exploits it. The quiet green check is exactly why a missed bug does more damage than a noisy one, which we cover in why SAST has false positives.

    Why does static analysis miss real vulnerabilities?

    Common reasons include a dangerous call the tool does not know about, a call target hidden behind dynamic dispatch or reflection, routes and behavior bound by framework config the parser never sees, values that cross a queue or another service and break the taint trail, and logic bugs like broken access control that have no dangerous sink to match.

    How do you reduce static analysis false negatives?

    Prove reachability instead of matching sink shapes, so you follow the specific value through the specific branches across wrappers and call boundaries. For logic bugs like broken access control, you need reasoning about what the app should allow, not pattern matching. See reachability analysis for security for the mechanics.


    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.

  • Symbolic Execution Explained

    Symbolic Execution Explained

    Most program analysis runs code with real values: you pass in the number 5, the string “admin”, a JSON body, and you watch what happens. Symbolic execution does something different. Instead of a concrete value, it feeds the program a symbol that stands for any possible input, then works out, branch by branch, exactly which inputs would drive execution down each path. That lets it answer a precise question: what input reaches this specific line?

    What symbolic execution actually does

    Pick a small function. It takes an amount and a balance and decides whether a withdrawal is allowed.

    def withdraw(amount, balance):
        if amount > balance:
            return "denied"
        return balance - amount

    A normal run needs numbers. You call withdraw(30, 20) and get “denied”. Symbolic execution skips the numbers. It treats amount as a symbol, call it A, and balance as a symbol B. Then it walks the code. When it hits the if, it cannot pick a side, because it does not know the values. So it forks. It follows both branches and remembers, for each one, the condition that had to be true to get there. That remembered condition is the path constraint.

    • Path one takes the if. Its path constraint is A > B. Any input where the amount is larger than the balance lands here and returns “denied”.
    • Path two falls through. Its path constraint is A <= B. Any input where the amount is at most the balance lands here and returns the new balance.

    Now the useful step. A constraint solver takes a path constraint and hands back concrete values that satisfy it, or tells you none exist. Ask it to satisfy A > B and it might return A = 1, B = 0. Ask it to satisfy A <= B and it might return A = 0, B = 0. You now have a real test input for each path, derived from the code itself rather than guessed.

    Why symbolic execution matters for finding bugs

    The point is not to enumerate paths for their own sake. The point is to prove that a dangerous line is reachable with a specific input. Take a function with a clear flaw.

    def write_slot(n, table):
        # table has exactly 8 slots
        if n > 100:
            table[n] = 1   # n is far past the end of table
        return table

    The write on line four is out of bounds whenever the branch is taken. Symbolic execution treats n as a symbol, reaches the if, and records the path constraint n > 100 for the branch that performs the write. Hand that to the solver and it returns something like n = 101. That is not a maybe. It is a concrete input that drives the program to the bad line, which you can drop straight into a test and watch fail.

    A path constraint plus a solver turns “this line looks reachable” into “here is the exact input that reaches it.”

    This is the difference between a warning and a proof. A cheaper analysis might flag that line as suspicious. Symbolic execution can produce the input that triggers it, which is the evidence a developer needs to believe the finding and fix it.

    The honest limit: path explosion

    There is a hard ceiling, and it is worth being blunt about it. Every branch forks the analysis into more paths. Two if statements in a row give four paths. Ten give more than a thousand. A loop that can run an unknown number of times multiplies paths on every iteration. This is path explosion, and it is the reason pure symbolic execution does not scale to a whole large program on its own.

    Picture a request handler with twenty branches feeding into a parser with its own loops. The number of distinct paths is astronomical, and the solver has to reason about the constraints along each one. You run out of time or memory long before you finish. Anyone who tells you symbolic execution just scans your entire codebase and prints every bug is skipping this part.

    How it fits with cheaper analysis

    The way to use symbolic execution well is to point it at a small target, not the whole program. You let a lighter analysis do the wide search, then spend the expensive symbolic work only where it pays off. A common shape looks like this:

    • Run a fast, whole program pass to find a candidate location, for example a memory write or a query built from user input that might be reachable from an entry point.
    • Use reachability analysis to check whether any path even connects the input to that location. If nothing reaches it, you stop and spend nothing more.
    • Only for the candidates that survive, run symbolic execution on that slice to produce the actual input that reaches the line, or to prove that the path constraint is unsatisfiable and the warning was a false alarm.

    That last case matters as much as the first. When the solver reports that a path constraint has no solution, it has proven the path is infeasible. That is a clean way to rule out a candidate rather than leave it as noise a person has to triage by hand.

    A structural model of the code makes the handoff cleaner. When your candidates come out of a code property graph, each one already carries the path from input to sink, so the symbolic step knows exactly which slice to solve instead of the whole function.

    Concolic execution in one line

    There is a middle option worth knowing by name. Concolic execution mixes concrete and symbolic: it runs the program with a real input to pick one concrete path, keeps the symbolic constraints for the branches along that path, then flips one constraint and asks the solver for an input that takes the other side, which steers exploration toward new paths without forking on every branch at once.

    The takeaway

    Symbolic execution is a precise tool with a narrow reach. It replaces guessed inputs with symbols, records a path constraint at every branch, and uses a solver to turn a constraint into the exact input that reaches a line, or to prove no such input exists. It cannot swallow a whole large program because paths explode, so it works best as the proving step after a cheaper analysis has narrowed the search. For more on where broad scanning ends and focused reasoning begins, read scanners vs research. Proving that a real input reaches a real flaw, rather than listing patterns that might matter, is exactly the kind of verification UnboundCompute is built around, and you can read how we think about it on our about page.

    Frequently asked questions

    What is symbolic execution in simple terms?

    Instead of running a program with real values, symbolic execution feeds it a symbol that stands for any possible input. At each branch it forks and records the condition that had to be true to take that path, called a path constraint. A constraint solver then turns a path constraint into a concrete input, for example the exact value that reaches a vulnerable line.

    What is a path constraint?

    A path constraint is the set of conditions that must all hold for execution to follow one particular path. For a function with if amount > balance, the branch that is taken has the path constraint amount > balance and the branch that falls through has amount <= balance. A solver reads a path constraint and returns concrete inputs that satisfy it, or reports that none exist.

    What is path explosion and why does it limit symbolic execution?

    Every branch forks the analysis into more paths, so two ifs give four paths, ten give over a thousand, and a loop multiplies paths on each iteration. This is path explosion, and it is why pure symbolic execution does not scale to a whole large program on its own. The fix is to point it at a small slice that a cheaper analysis has already flagged, not the entire codebase.

    How is concolic execution different from symbolic execution?

    Concolic execution mixes concrete and symbolic. It runs the program with a real input to pick one concrete path, keeps the symbolic constraints for the branches along that path, then flips one constraint and asks the solver for an input that takes the other side. This steers exploration toward new paths without forking on every branch at once.


    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.

  • The Program Dependence Graph Explained

    The Program Dependence Graph Explained

    When an analyst asks “what can affect this dangerous call”, they are really asking a graph question. A program dependence graph answers it. It is a way to draw a function so that the edges show which statements feed which other statements, and which statements only run because of a branch. Once you have that picture, you can start at any line and read off exactly what influences it.

    What a program dependence graph is

    A program dependence graph has one node per statement and two kinds of edge. That is the whole idea. The value of the graph is that it drops the parts of a program that do not matter for a given question and keeps the parts that do.

    • Data dependence. Statement B uses a value that statement A defined. If A writes total and B reads total with no other write in between, there is a data dependence edge from A to B.
    • Control dependence. Whether statement B runs at all depends on a branch condition at statement A. If B sits inside an if and the test at A decides whether that body executes, there is a control dependence edge from A to B.

    Regular source code hides both of these behind line order. Line 8 might depend on line 2 and ignore lines 3 through 7 completely. The program dependence graph makes that real relationship explicit so you do not have to hold it in your head.

    A small function, edge by edge

    Here is a short function that charges a user. Read it once, then we will label every statement and list its edges.

    def charge(user_id, amount):
        balance = get_balance(user_id)          # S1
        fee     = amount * 0.02                  # S2
        total   = amount + fee                   # S3
        if balance >= total:                     # S4
            record = build_record(user_id, total)  # S5
            db.execute(record)                   # S6
        return total                             # S7

    The data dependence edges

    Follow each value from where it is written to where it is read.

    • S3 reads fee, so S2 to S3.
    • S4 reads balance and total, so S1 to S4 and S3 to S4.
    • S5 reads total, so S3 to S5.
    • S6 reads record, so S5 to S6.
    • S7 reads total, so S3 to S7.

    The two parameters, user_id and amount, are the roots. They feed S1, S2, and S5 directly.

    The control dependence edges

    Now ask which statements only run because a test allowed them to.

    • S5 and S6 live inside the if at S4. So S4 to S5 and S4 to S6.
    • S1, S2, S3, S4, and S7 run every time the function is called. They have no control dependence inside this function.

    Notice that data and control are different questions with different answers. S6 has no data edge from S4, because it does not read the boolean the test produced. But it has a control edge from S4, because the branch decides whether S6 happens at all. Miss either edge type and your picture of the function is wrong.

    Program slicing: reading the graph backward and forward

    Once the edges exist, slicing is just a walk. A backward slice from a statement follows dependence edges in reverse to collect every statement that can affect it. A forward slice follows edges the other way to collect everything that statement affects.

    Take the backward slice from S6, the database call. Walk the edges into it and keep going.

    • S6 pulls in S5 by data and S4 by control.
    • S5 pulls in S3 by data and S4 by control.
    • S4 pulls in S1 and S3 by data.
    • S3 pulls in S2 by data.
    • S2 and S1 pull in the parameters.

    The backward slice from S6 is {S1, S2, S3, S4, S5} plus both parameters. Look at what fell out: S7, the return total. It reads total, so it is part of the function, but nothing about it can change what S6 does. The slice removed it correctly. You now hold the smallest set of statements that decides the behavior of that one call.

    A backward slice from a dangerous call is a complete, honest answer to “what can influence this line”, with the unrelated code already deleted.

    Why the program dependence graph matters for security

    An analyst looking at a risky operation asks one question first. What reaches this? If db.execute can run attacker controlled text, that is a possible injection. If it cannot, the call is fine. The backward slice from that sink is exactly that answer, computed instead of guessed.

    Say a request handler ends in a raw query. The backward slice tells you every statement between the request parameter and the query string. If a validation step or an escaping call sits on that slice, the input is checked before it reaches the sink. If the slice runs from the parameter straight into the query with nothing in between, you have found the shape of a real bug. The graph turns a vague worry into a finite list of statements to read.

    Control dependence carries its own weight here. An access check is usually an if that guards the sensitive action. If the sink has no control edge from that check, the check does not actually gate it, and the guard is decorative. That gap is the kind of thing a scanner that only matches text will walk right past. For more on why understanding an app beats matching patterns, read scanners vs research.

    Where it sits in the bigger picture

    The program dependence graph is not the whole story on its own. It is one layer that a richer structure merges together. A code property graph stitches the syntax tree, the control flow graph, and the program dependence graph into a single queryable model, so you can ask about structure and dependence in one place.

    Slicing also assumes you already know where each value is defined and used, across function calls and reassignments. Computing that is the job of data flow analysis, which works out the definitions that can reach each use. The program dependence graph is the map. Data flow analysis is how the map gets drawn.

    The takeaway

    Two edge types, one node per statement, and a walk in either direction. That is enough to answer the question an analyst cares about most: given a dangerous call, show me only the code that can steer it. A backward slice from a sink hands you that set with nothing extra to read. This is the kind of structural reasoning UnboundCompute leans on when it studies how an app is meant to work and looks for the assumptions that quietly fail. You can read more about that approach on our about page.

    Frequently asked questions

    What are the two kinds of edge in a program dependence graph?

    A program dependence graph has data dependence edges and control dependence edges. A data dependence edge runs from statement A to statement B when B reads a value that A defined. A control dependence edge runs from a branch at A to B when the test at A decides whether B runs at all. One node per statement, two edge types, and that is the whole model.

    What is program slicing?

    Slicing is a walk over the dependence edges. A backward slice starts at one statement and follows edges in reverse to collect every statement that can affect it. A forward slice follows edges the other way to collect everything that statement affects. The result is the smallest set of statements that matters for the question you asked.

    Why is a backward slice useful for security?

    A backward slice from a dangerous call, such as a database query or a shell command, is exactly the set of statements that can influence that call. If the slice runs from a request parameter straight into the sink with no validation or escaping on the way, you have found the shape of an injection bug. If a check sits on the slice, the input is gated before it reaches the sink.

    How does the program dependence graph relate to a code property graph?

    The program dependence graph is one of the layers a code property graph merges. A code property graph stitches the syntax tree, the control flow graph, and the program dependence graph into a single model you can query, so you can ask about structure and dependence at the same time. Data flow analysis is what computes the definitions and uses that the dependence edges rest on.


    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.

  • Points-to Analysis for Security

    Points-to Analysis for Security

    When a security tool reads your code, it keeps asking one quiet question: when I see the name x, which object in memory does it stand for right now? That question is what points-to analysis answers, and getting it right is the difference between a real bug and a wasted afternoon chasing one that was never there. This post is about heap and alias reasoning only, the part that decides whether two names touch the same thing.

    What points-to analysis actually computes

    A variable in source is just a label. The object it refers to lives on the heap, and one object can wear many labels at once. Points to analysis builds a map: for each variable or pointer at each point in the program, which set of objects could it be holding. If the set has one object, the tool knows exactly what you are touching. If the set has ten, the tool is honest that it is not sure.

    Here is the plain version:

    • An object is a thing on the heap, usually named by where it was created, like “the dict made on line 12”.
    • A variable points to a set of those objects.
    • Two variables alias when their sets overlap, meaning they can name the same object.

    Aliasing is the whole game. If you write to memory through one name, every other name that aliases it sees the change too. Miss that and your model of the program is wrong.

    Aliasing: two names, one object

    Look at this Python snippet:

    profile = load_profile(request)   # object A, tainted
    view = profile                    # view now points to object A too
    view.bio = escape(view.bio)       # sanitize through 'view'
    html = render(profile.bio)        # use through 'profile'

    Does the last line ship raw user input into your HTML? It depends entirely on whether view and profile point to the same object. They do here, because view = profile copies the reference, not the object. So view.bio and profile.bio are the same field. The escape call cleans it, and the render is safe.

    Now change one line:

    profile = load_profile(request)   # object A, tainted
    view = copy_profile(profile)      # object B, a fresh copy
    view.bio = escape(view.bio)       # sanitize object B
    html = render(profile.bio)        # still object A, still tainted

    Same shape, opposite verdict. Now view points to object B and profile still points to object A. The sanitizer cleaned the copy, and the render sends the untouched original straight into the page. That is a real cross site scripting bug.

    The two snippets read almost identically. Only the heap tells them apart, and only a tool that tracks which object each name holds can call one safe and the other a bug.

    A tool without alias reasoning has to guess. Guess that the sanitize always counts and it misses the second bug. Guess that it never counts and it screams about the first one, which is fine. That guessing is where a lot of scanner noise is born.

    Why this decides real from false

    Injection findings all have the same skeleton: tainted input reaches a dangerous sink. The catch is that the value often passes through several names, assignments, and function calls on the way. A sanitizer might sit on one of those names. Whether it protects the value at the sink comes down to a single question: is the sanitized name the same object as the one that reaches the sink? Answer it wrong and you either report a clean path as vulnerable or wave a live bug through. Both are expensive.

    Flow sensitivity and context sensitivity: accuracy versus cost

    Points to analysis comes in grades. The two knobs that matter most are flow sensitivity and context sensitivity, and both trade precision for compute.

    Flow sensitivity

    A flow insensitive analysis ignores statement order. It says “across this whole function, x can point to any object it ever pointed to.” Cheap, but sloppy. Consider:

    x = safe_object()
    use(x)              # x is safe here
    x = tainted_object()
    use(x)              # x is tainted here

    A flow insensitive tool merges both assignments and decides x might be tainted at the first use too, which is false. A flow sensitive tool respects the timeline: safe at line 2, tainted at line 4. More accurate, more memory, more time, because it now tracks the map at every point rather than once per function.

    Context sensitivity

    Context sensitivity is the same idea across function calls. When one helper is called from two places, does the analysis keep the calls apart or blur them together?

    def wrap(v):
        return Box(v)
    
    a = wrap(user_input())   # Box holds tainted
    b = wrap(config_value())  # Box holds safe

    A context insensitive tool analyzes wrap once and merges its callers, so it thinks both a and b might hold tainted data. A context sensitive tool treats each call site on its own and keeps b clean. The precision costs you: the analysis effectively re examines the callee per context, and contexts multiply fast on a large codebase. Every serious tool picks a budget, some bounded amount of context, and lives with the approximation past it.

    Points-to analysis and the false positive problem

    Here is the security payoff. A finding that says “tainted value reaches a sink” is only trustworthy if the tool knows the tainted name and the sink name are really the same object. When it cannot tell aliases apart, it falls back to conservative guessing, and conservative guessing on the heap is a top source of false positives.

    Picture a request handler that stashes user input in a shared dictionary, hands one entry to a validator, and passes a different entry to a query builder. A weak analysis sees “user input went into the dictionary, dictionary data reached the query” and files a SQL injection report. A tool with sharper heap reasoning sees that the validated entry and the queried entry are separate objects, and that the queried one was never tainted. One less false alarm, one more reason a developer keeps trusting the tool.

    This is why heap reasoning is not an academic detail. It is the machinery that separates a genuine tainted path from a coincidence of names. If you want the bigger picture of why static tools cry wolf, read why SAST has false positives. Points to analysis is one of the graph layers that sits underneath a modern representation of code, described in what is a code property graph.

    Where the limits are

    No analysis nails the heap perfectly, and honest tooling admits it. Dynamic dispatch, reflection, function pointers, and data that arrives from outside the program all force the analysis to widen its guesses. The practical answer is not to demand a perfect map but to know when the map is fuzzy and treat those findings with extra care, human review included.

    That gap between what a name says and what the heap actually does is exactly the kind of thing an autonomous researcher that reasons about how an app really behaves is built to check, rather than trusting the pattern at face value. If that is the way you want your code looked at, here is who we are, and here is more on scanners versus research.

    Frequently asked questions

    What does points-to analysis do?

    It computes, for each variable or pointer at a point in the program, the set of heap objects it could refer to. A single object means the tool knows exactly what you are touching, while a large set means it is uncertain. Security tools use this map to decide whether a tainted value and a sink really name the same object.

    What is aliasing and why does it matter for a bug?

    Aliasing is when two names point to the same object, so writing through one name is visible through the other. It decides real bugs from false ones. If you sanitize a value through one reference but use it through an alias, the fix only counts when both references point to the same object. When they point to separate objects, the original stays tainted and the bug is real.

    What is the difference between flow sensitivity and context sensitivity?

    Flow sensitivity respects statement order, so a variable that is safe on one line and tainted on the next is tracked as two different states. Context sensitivity keeps calls to the same function separate, so a helper called with safe data in one place and tainted data in another does not get merged. Both raise accuracy and both cost more compute, so tools pick a budget.

    How does heap reasoning cut false positives?

    A tainted value often passes through many names before it reaches a sink. If the analysis cannot tell aliases apart, it guesses conservatively and reports paths that are actually safe. Sharper heap reasoning sees that a validated object and a queried object are separate, so it drops the false alarm and only reports paths where a tainted object truly reaches the sink.


    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.

  • Call Graph Analysis Explained

    Call Graph Analysis Explained

    Most serious bugs do not live in one function. Input arrives in one place, travels through a few helpers, and reaches a dangerous line somewhere else entirely. To follow that path you need a map of which function calls which, and building that map is what call graph analysis gives you. This post explains what a call graph is, why it is the backbone of any analysis that crosses function boundaries, and where it gets hard and honestly stays an approximation.

    What a call graph is

    A call graph is a simple idea drawn out in full. Each function in your program is a node. Each call from one function to another is an edge, drawn from the caller to the callee. That is the whole structure. If handleRequest calls parseBody, and parseBody calls saveToDb, you get three nodes and two edges, a little chain you can walk.

    Once you have that map, two questions become answerable. Point at a function and ask who calls this, and you follow the edges backward. Ask what does this call, and you follow them forward. Those two questions sound small, but almost every question that matters in security is built out of them.

    Why call graph analysis is the backbone

    Here is the reason it matters. A bug where user input arrives in one function and reaches a sink in another is invisible if you only read one function at a time. You have to connect the two, and the call graph is the thing that connects them.

    Take a small example spread across three functions:

    def handle(req):
        name = req.query["name"]
        return build_page(name)
    
    def build_page(value):
        return render(value)
    
    def render(text):
        return "<div>" + text + "</div>"

    The tainted value enters in handle. It is passed to build_page, then to render, where it lands in raw HTML with no escaping. That is a cross site scripting bug. But no single function looks wrong on its own. handle just reads a query parameter. render just concatenates two strings. Only when you walk the edges from handle to build_page to render does the flow appear. Tracking a tainted value across those hops is called interprocedural taint analysis, and you can read more on interprocedural taint analysis. None of it works without the call graph underneath.

    A single function almost never looks guilty. The bug lives in the edges between functions, which is exactly what a call graph makes visible.

    Direct calls are easy

    When the call target is written in the text, the edge is obvious. saveToDb(row) names the function it calls. A parser reads that line, sees the name, and draws an edge to saveToDb. This is the easy case, and for a lot of straight line code it is most of the graph. Direct calls are why call graph tools feel reliable at first glance.

    Indirect calls are the hard part

    The trouble starts when the target is not written in the text. Plenty of real code decides at run time which function to call:

    • Virtual methods. animal.speak() could run the dog version or the cat version depending on the object’s real type.
    • Function pointers. A C struct holds a pointer to a handler that gets set somewhere far away and called later.
    • Callbacks. You pass a function into sort or an event listener, and the library calls it back with no name at the call site.
    • Dynamic dispatch and reflection. Code that does getattr(obj, method_name)() picks the target from a string, sometimes a string that came from input.

    In every one of these, the call site does not say who it calls. The actual target is decided by data that flows in at run time. So the analysis has to guess, and a call graph stops being a fact and becomes an approximation.

    Overapproximation versus underapproximation

    There are two ways to be wrong about an indirect call, and they fail in opposite directions.

    • Overapproximation adds every target that could possibly be called. If a function pointer might point at any of five handlers, draw edges to all five. This is safe, because you never miss a real call, but it is noisy. You end up chasing flows through targets that never actually run, and the graph gets crowded.
    • Underapproximation only draws edges it is sure about and skips the rest. This is quiet and clean, but unsafe, because a target you dropped might be the exact one the attacker reaches. A missed edge is a missed bug.

    Good tools lean toward overapproximation for anything security relevant, then work to trim the noise, because a false path costs you time but a missing path costs you the finding. The honest framing is that no call graph over a language with dynamic dispatch is exact. It is a careful estimate, and knowing which way it errs tells you how to read its results.

    Where text search quietly fails and a real graph does not

    The clearest reason to build a real call graph instead of grepping is that names lie. Search finds the string you typed. It does not follow a rename or an alias.

    Suppose a function is imported under a new name:

    from db import execute as run_query
    
    def save(row):
        run_query("INSERT ...")   # this calls db.execute

    Now search your codebase for callers of execute. The line above never matches, because the text says run_query. To a person skimming grep results, save looks like it has nothing to do with execute. It is a hidden caller. A call graph built from a real parse resolves the import, sees that run_query is a local alias for db.execute, and draws the edge anyway. Ask it who calls execute and save shows up. The same holds for a method renamed in a subclass, a wrapper that forwards a call, or an object bound to a shorter local variable. Text does not track identity across a rename. A graph built from the compiler’s own understanding does.

    This is what a precise call graph from a real parse is for. It answers who calls this and what does this call without dropping an aliased caller because the letters changed. The public engine we build for this is lachesis, which reads the code the way the compiler does rather than the way search does. It is the same structural idea behind a code property graph, which you can read about in what is a code property graph.

    Reading a call graph honestly

    Because indirect calls make the graph an estimate, treat every edge as evidence with a confidence, not as a verdict. A direct call is solid. A function pointer resolved through data flow is a good guess. A call through reflection off an input string may be unresolved, which means the tool is telling you it does not know, not that nothing is called there. That last case is where a human still has to read the source. Knowing the difference between scanners that pattern match and research that reasons about a program is its own topic, covered in scanners vs research.

    A call graph is the map that lets an analysis cross function boundaries at all, and getting the indirect edges right is most of the work. That mapping is exactly the groundwork an autonomous researcher needs before it can reason about how one function’s assumptions break in another, which is what we build toward at UnboundCompute.

    Frequently asked questions

    What is a call graph?

    A call graph is a map of a program where each function is a node and each call from one function to another is an edge drawn from caller to callee. If handle calls build_page and build_page calls render, you get three nodes and two edges you can walk. It lets you ask who calls a function and what a function calls.

    Why does call graph analysis matter for finding bugs?

    Many serious bugs span more than one function. Input arrives in one place and reaches a dangerous line somewhere else, so no single function looks wrong on its own. The call graph connects the caller to the callee, which is what lets an analysis follow a tainted value across function boundaries and see the flow that a single function view hides.

    Why are indirect calls hard for a call graph?

    With virtual methods, function pointers, callbacks, and reflection, the call target is decided by data at run time and is not written at the call site. The tool has to estimate the targets, so the graph becomes an approximation. It can overapproximate by adding every possible target, which is safe but noisy, or underapproximate by dropping uncertain ones, which is quiet but can miss a real bug.

    Why not just grep for who calls a function?

    Text search only finds the string you typed, so it misses a caller when a function is renamed or imported under an alias. If db.execute is imported as run_query, a search for execute never matches the call. A call graph built from a real parse resolves the alias and still draws the edge, so the hidden caller shows up.


    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.

  • Taint Analysis Explained

    Taint Analysis Explained

    Most serious injection bugs come down to one story: data that a user controls travels through your code and reaches a dangerous function without ever being cleaned. Taint analysis is the technique that follows that story automatically. It marks untrusted input as tainted, tracks the taint as the value moves through the program, and raises an alert if it lands somewhere sensitive without passing an accepted sanitizer.

    What taint analysis actually does

    Think of taint as a stain. The moment a value comes from outside your trust boundary, you color it. Then you watch where that color spreads. If you copy the value into a new variable, the new variable is stained too. If you glue it onto a string, the whole string is stained. If you pass it into a function, whatever the function returns from it stays stained. The stain only comes off when the value goes through a function you have declared safe for the place it is heading.

    The check at the end is simple to state. Did a stained value reach a dangerous operation? If yes, and no approved cleaning step happened on the way, that is a finding.

    The three ingredients: sources, sinks, sanitizers

    Every taint engine is built from three lists. Get these right and the analysis works. Get them wrong and it either screams about nothing or stays silent on real bugs.

    • Sources are where untrusted data enters. A request parameter, a request body, an HTTP header, a cookie, a file you read, a message off a queue. Anything an attacker can influence.
    • Sinks are the dangerous operations where tainted data causes harm. A database query, a shell command, an HTML response, a file path, a call that builds more code to run.
    • Sanitizers are the steps that make a value safe for a specific sink. A parameterized query binder for SQL. HTML escaping for a web page. An allowlist check for a file path. A sanitizer is only valid for the sink it matches, which is a point people miss.

    That last point deserves weight. HTML escaping cleans a value for a web page and does nothing for a SQL query. A sanitizer is not a magic cleanser. It is safe for one destination and useless for another. The engine has to know which sanitizer counts for which sink, or it will wave a value through as clean when it is still loaded for a different target.

    A concrete SQL injection example, from parameter to query

    Here is the shape of bug taint analysis was built to catch. A search endpoint takes a name from the query string and builds a SQL statement out of it.

    name = request.args.get("name")        # source: tainted
    greeting = "Hello " + name             # taint propagates through concatenation
    query = "SELECT * FROM users WHERE name = '" + greeting + "'"
    db.execute(query)                      # sink: tainted value reaches SQL

    Walk the stain. On line one, name comes from the request, so it is tainted. On line two, we concatenate it into greeting, so greeting is tainted too. On line three, that value goes into the query string, which is now tainted. On line four, the tainted string reaches db.execute, a SQL sink. No parameter binding happened anywhere on that path. The engine reports SQL injection.

    Now the fixed version, where the same trace comes back clean.

    name = request.args.get("name")        # source: tainted
    query = "SELECT * FROM users WHERE name = ?"
    db.execute(query, [name])              # name is bound as a parameter, not spliced in

    The value is still tainted, but it never enters the SQL text. It rides in as a bound parameter, which the database treats as data and never as code. The parameter binder is the accepted sanitizer for a SQL sink, so the taint reaches the sink in a safe form and no alert fires.

    Propagation rules, briefly

    The interesting work is in the middle, between source and sink. Propagation rules say how taint moves. A few common ones:

    • Assignment. b = a makes b tainted if a is.
    • Concatenation and formatting. Join a tainted value with anything and the result is tainted.
    • Function return. Pass tainted data into a function and the value it returns is usually treated as tainted, unless the function is a known sanitizer.
    • Collections. Put a tainted value in a list or map and reading it back gives you tainted data.

    Real engines get pickier than this, and the picky parts are where cross function tracking lives. For how taint follows a value across function boundaries and call context, see interprocedural taint analysis. For a shared vocabulary of what counts as a source and what counts as a sink, see sources and sinks explained.

    Taint analysis does not ask whether code looks suspicious. It asks one question over and over: did untrusted data reach a dangerous place without being cleaned for that place.

    Static taint analysis vs dynamic taint analysis

    There are two ways to run the trace, and they trade off against each other.

    Static taint analysis: read the code, do not run it

    Static taint analysis reads the source and models data flow without executing anything. It can see every branch, including the error path that fires once a year and the admin route no test ever hits. That coverage is its strength. Its weakness is that it has to guess about things it cannot run: values that come from config, dynamic dispatch, a call into a library it does not parse. So it can flag a path that a real check would have made safe, which is a false positive.

    Dynamic taint analysis: track it at run time

    Dynamic taint analysis tags real values while the program runs and watches the tags flow through actual execution. Because it sees what really happened, its findings are precise and its false positive rate is lower. The catch is coverage. It only tracks code that actually runs during the test. A route no request touches is a route it never checks, so a bug on an unexercised path stays invisible.

    The tradeoff in one line: static sees every path but guesses about reality, dynamic sees reality but only the paths you exercise. Neither dominates. Serious setups often use both.

    Where taint analysis reaches its limit

    Taint analysis is strong on injection because injection is exactly a source to sink flow. It is weak where the bug is not about tainted data at all. Consider GET /api/invoices/42 from a user who only owns invoice 41. The invoice id is technically tainted, but it never reaches a SQL string unsafely and no sanitizer is missing. The query is parameterized and clean. Taint analysis sees nothing wrong, because the fault is that the app never checked ownership. That is broken access control, and no source to sink trace describes it.

    Knowing that boundary is the point. Taint analysis is a sharp tool for one large class of bugs and blind to another. For more on where pattern tracing ends and reasoning about an app begins, read scanners vs research, and for the graph structure these traces run over, see what is a code property graph.

    UnboundCompute runs taint analysis as one input among many, then tests the assumptions an app makes on top of it, which is where the access control style bugs that taint alone cannot see tend to hide. Read more on our about page.

    Frequently asked questions

    What is taint analysis in simple terms?

    Taint analysis marks data from an untrusted source as tainted, then follows that value as it is copied, joined, and passed through functions. If a tainted value reaches a dangerous operation like a database query or a shell command without passing an accepted sanitizer for that operation, the analysis raises an alert. It is the main technique behind finding injection bugs automatically.

    What are sources, sinks, and sanitizers?

    Sources are where untrusted data enters, such as a request parameter, header, or cookie. Sinks are dangerous operations where that data can cause harm, such as a SQL query or an HTML response. Sanitizers are steps that make a value safe for a specific sink, like a parameter binder for SQL or HTML escaping for a web page. A sanitizer is only valid for the sink it matches.

    What is the difference between static and dynamic taint analysis?

    Static taint analysis reads the source code without running it, so it can trace every branch, but it has to guess about values that come from config or code it cannot parse, which can cause false positives. Dynamic taint analysis tags real values while the program runs, so its findings are precise, but it only sees code paths that actually execute during testing. Static covers more paths, dynamic reflects real behavior.

    Can taint analysis find broken access control?

    Usually no. A request like GET /api/invoices/42 from a user who only owns invoice 41 carries a tainted id, but that id never reaches a sink unsafely and no sanitizer is missing. The query is clean, so taint analysis sees nothing wrong. The fault is that the app never checked ownership, which is a logic bug that no source to sink trace describes.


    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.

  • Missing Authorization Check: The Handler That Skips It

    Missing Authorization Check: The Handler That Skips It

    A missing authorization check rarely looks like a bug. It looks like a function that does slightly less than the one next to it. Two request handlers sit in the same file, both load a record by id, both return it as JSON. One compares the record to the caller first. The other does not. To a text search they are the same three lines. To the person reading a 400-file service at 5pm, they are the same too.

    Two handlers that look identical

    Here are two Express handlers from the same controller. Read them the way grep reads them.

    async function getInvoice(req, res) {
      const tenantId = req.user.tenantId;
      const invoice = await db.invoices.findById(req.params.id);
      if (invoice.tenantId !== tenantId) return res.status(403).end();
      return res.json(invoice);
    }
    
    async function getDocument(req, res) {
      const doc = await db.documents.findById(req.params.id);
      return res.json(doc);
    }

    getDocument takes an id from the URL, hands it to findById, and returns whatever comes back. It never reads req.user. Any authenticated caller who changes the id in the path reads another tenant’s document. That is a broken object level authorization bug, the class most people still call IDOR, and it is the single most common finding in access-control review for a reason: nothing about the code looks wrong.

    Why grep and a symbol index both miss it

    Search the repo for findById and you get both handlers. Both are real call sites. Both take a request-controlled id. A symbol index, the thing your editor’s “go to definition” runs on, tells you the same story: two references to the same method, same shape, same types. LSP, ctags, and SCIP all answer the question where does this name appear. That question cannot separate these two functions, because the difference between them is not a name. It is an access control check that one function runs and the other skips, and a check that is absent leaves nothing for a text search to find.

    This is the gap a code graph closes over grep. The bug is not in what the code says. It is in what the code does with a value.

    Follow the value, not the name

    Trace the request through getInvoice as a value, not as text. The caller’s tenantId comes off req.user. It reaches a comparison, invoice.tenantId !== tenantId. That comparison decides whether the response is the record or a 403. So the identity of the caller reaches a guard, and the guard dominates the return. The record only leaves the building after the check clears.

    Now trace getDocument. req.params.id reaches findById. The returned doc reaches res.json. Nothing off req.user reaches anything between the lookup and the response. The path from a request-controlled value to the returned object has no guard on it.

    Stated that way, the two functions are not similar at all. One has a source-to-sink path that passes through an ownership check. The other has the same path with the check missing. That property lives in data flow, in how the value moves, and it is invisible to anything that reads the code as a bag of names. This is the same machinery taint analysis uses to follow untrusted input to a dangerous sink, pointed at authorization instead of injection.

    Doing it with a code graph

    A code property graph stores syntax, symbols, calls, and the dataflow layer in one queryable structure, so “does a request value reach this sink without passing a guard” is a query you run, not a review you do by eye. Lachesis is an open-source one for C, Python, and TypeScript. Install it and build a graph from the bundled fixture that carries these exact two handlers.

    python -m pip install lachesis-cpg
    
    lachesis-analyze lachesis/frontends/typescript/fixtures/project example.kuzu
    lachesis-query --format text example.kuzu handler-security getDocument

    The answer is not a guess and not a severity score. It is the shape of the path.

    "status": "UNGUARDED",
    "guard_signal": null,
    "differential_siblings": [ "getInvoice" ]

    getDocument reaches its database call with no guard on the path from the request. guard_signal is null because there is no check to point at. And the record names its guarded twin directly: getInvoice, the sibling that does the same job with the check in place.

    Why the sibling is the whole answer

    A finding that says “this looks unguarded” is a lead. A finding that says “this is unguarded, and the function three lines up does the identical lookup with an ownership check” is a repro. The guarded sibling is the specification the unguarded one failed to meet. You are not arguing about whether a check belongs there. A near-identical function in the same file already proves it does. That is the difference between a signal a scanner drops and one a reviewer acts on.

    On your own code the entrypoint is the same idea without naming a fixture:

    lachesis scan ./your-service

    It builds and caches the graph, then reports the handlers that reach a sensitive effect on an unguarded path, each one with its guarded peers named. The graph also serves over MCP, so an AI agent reviewing a pull request can ask “does this new handler reach the database without an ownership check, and which sibling shows the check it should have” and get a path back instead of a grep.

    Where this stops

    Following the value is precise, not omniscient. If the ownership check hides behind dynamic dispatch the analyzer cannot resolve, a call through a table it never sees, a check applied by a framework decorator it does not model, the path can read as unguarded when a guard is really there. A good code graph says so: it marks the edge conservative rather than hiding the doubt, and you read the result as evidence, not a verdict. The point is not that the tool is always right. The point is that it asks the question grep cannot phrase, “did a request value reach this sink without passing a check,” and answers it for every handler in the tree at once, then hands you the sibling that proves the check was supposed to be there.

    A missing authorization check is not a line you can search for. It is a path with a guard removed, and you only see it if you follow the value.

    Frequently asked questions

    What is a missing authorization check?

    It is a code path where a request-controlled value reaches a sensitive action, a database lookup, an update, a file read, without any check that the caller is allowed to act on that object. The code runs fine and returns data. It just returns data the caller should not see, which is why it reads as normal in review.

    Why can’t grep find a missing authorization check?

    Grep and symbol indexes answer where a name appears. A missing check is the absence of code, not a name, so there is no string to match. Two handlers that both call findById look identical to a text search even when one validates ownership and the other does not.

    How is this different from IDOR or broken object level authorization?

    It is the same bug seen from the code side. IDOR and broken object level authorization describe what an attacker does, change an id and read another object. A missing authorization check is the source-level cause: a path from the request to the object with no ownership guard on it.

    Can static analysis find missing authorization checks?

    Pattern-based scanners struggle, because there is no fixed pattern for an absent check. A dataflow approach works: build a code property graph, then ask whether a request value reaches a sink without passing a guard, and compare each handler to its siblings that do guard the same sink.

    What tool finds missing authorization checks in code?

    Any tool that models dataflow rather than text can. Lachesis is an open-source code property graph for C, Python, and TypeScript that reports unguarded handler paths and names the guarded sibling that proves the check belongs there. Install it with pip install lachesis-cpg.


    Run this on your own code

    Lachesis is the open-source code property graph used in this post. It parses C, Python, and TypeScript with real compilers and answers dataflow questions like the one above, which handler reaches a sink on an unguarded path, across your whole tree at once. Install it with python -m pip install lachesis-cpg and point lachesis scan at a repo, or add it to your agent over MCP so a pull-request reviewer can trace a value instead of grepping for a name. The project is AGPL-3.0 and lives on GitHub. If you would rather have an autonomous researcher prove these bugs on a target you choose, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Sources and Sinks in Security Explained

    Sources and Sinks in Security Explained

    Almost every taint tool starts by learning two lists: where untrusted data enters your app, and where that data becomes dangerous. Those two lists are the sources and sinks of your program, and the words are the core vocabulary of every injection bug. Get them right and the rest of the analysis has a chance; get them wrong and whole classes of bugs walk straight past the scanner.

    What sources and sinks actually mean

    The vocabulary is small and worth learning exactly, because people use these words loosely and then talk past each other.

    • A source is any place attacker controlled data enters the program. A query parameter, a request header, a cookie, an uploaded file, a message off a queue, an environment variable set by an untrusted caller. If a stranger can influence the value, it is a source.
    • A sink is a place where data is dangerous if it is attacker controlled. A SQL query, a shell command, an HTML response, a file path, an HTTP redirect target. The call itself is not a bug; it becomes one when tainted data reaches it.
    • A sanitizer is code that cleans a value so it is safe for a specific sink. Parameter binding for SQL, HTML escaping for markup, an allow list for a file path. A sanitizer turns tainted data back into trusted data, but only for the sink it was built for.

    The whole model is one sentence. If data from a source reaches a sink without passing a sanitizer that fits that sink, you likely have a bug. Everything a taint engine does is bookkeeping around that sentence.

    The same value, from a source to a sink

    The clearest way to see it is to follow one value. In each example below, the same user string starts at a source and ends at a sink with no cleaning in between. The bug class changes, but the shape does not.

    SQL injection

    The source is the name parameter. The sink is the database driver call.

    name = request.args.get("name")            # source
    query = "SELECT * FROM users WHERE name = '" + name + "'"
    db.execute(query)                          # sink

    A request of ?name=' OR '1'='1 changes the meaning of the query. The fix is a sink specific sanitizer: bind the value as a parameter so the driver treats it as data, never as SQL.

    db.execute("SELECT * FROM users WHERE name = ?", [name])

    Cross site scripting

    Same source idea, different sink. Here the sink is the HTML response.

    comment = request.form["comment"]          # source
    response.write("<div>" + comment + "</div>")  # sink

    If comment is <script>steal()</script>, the browser runs it. The sanitizer for this sink is HTML escaping, which turns < into &lt; so the tag renders as text instead of code.

    Command injection

    Now the sink is a shell.

    host = request.args.get("host")            # source
    os.system("ping -c 1 " + host)             # sink

    A value of 8.8.8.8; rm -rf / runs a second command. The right sanitizer here is not escaping at all. It is passing arguments as a list so no shell parses them, or validating against a strict allow list.

    subprocess.run(["ping", "-c", "1", host])

    Path traversal

    The sink is a file path.

    fname = request.args.get("file")           # source
    open("/var/data/" + fname)                 # sink

    A value of ../../etc/passwd escapes the intended folder. The sanitizer resolves the final path and checks it still sits inside /var/data, rejecting anything that climbs out.

    A sink is only dangerous in one context

    This is the part people miss, and it is why a sanitizer is sink specific. Cleaning is not a single switch you flip on a value. It is defined relative to the sink you are heading into.

    Take a value that has been HTML escaped so it is safe to drop into a page. Feed that same escaped value into a shell command and it is still dangerous, because shell metacharacters are not the ones HTML cares about. Escaping < and > does nothing about ; or | or backticks. The reverse is just as true: a value shell quoted for a command is not safe to write into HTML.

    Safe is never a property of a value on its own. It is a property of a value with respect to one sink. There is no such thing as data that is clean everywhere.

    Concrete case. Suppose a username is escaped for HTML on the way into a profile page, and that works. Later a developer reuses the same username to name a log file. The HTML escaping did nothing for the file path sink, and ../../ in a username now writes outside the log folder. One value, two sinks, one sanitizer that only covered the first. The mismatch is the bug.

    Why sources and sinks are the first step of any taint tool

    Before a tool can trace anything, it has to know what to trace from and what to trace to. That catalog of sources and sinks is the starting configuration of every taint engine, whether it reads source code statically or watches the app at run time. The propagation in the middle, how the tool carries taint from one to the other across variables and function calls, is a separate topic. We cover the tracing itself in source to sink dataflow analysis and the harder cross function version in interprocedural taint analysis. This post is only about the two endpoints.

    The catalog matters because it sets the ceiling on what can ever be found. A tool cannot report a flow it was never told to look for.

    • If a source is missing, data from it looks trusted, and every bug that starts there is invisible.
    • If a sink is missing, data can pour into it and nothing flags the flow, because the tool does not know that call is dangerous.
    • If a sink is miscategorized, for example a template render function labeled as a plain string call, the tool applies the wrong rules and misses the injection.

    Here is why that is worse than a single missed bug. A missing sink is not one false negative, it is a whole class of them. Say your framework adds a new way to build raw HTML and the sink list never learns about it. Every use of that call across the entire codebase, past and future, is now a blind spot. One line in a catalog decides whether a hundred real flows are seen or silently dropped. That is why serious tools treat the sink list as a living thing, and why a well modeled graph of the code, like a code property graph, is built to make sources and sinks easy to enumerate and keep current.

    Putting the vocabulary to work

    When you read a finding, or write a check, or argue about whether something is exploitable, name the three parts out loud. Where is the source. Where is the sink. Is there a sanitizer between them, and does it fit that sink. Most injection disputes dissolve once those three questions are answered plainly, because the argument was usually about a sanitizer that fit the wrong context.

    Cataloging sources and sinks is where every scanner begins, but a fixed list only finds the bug shapes it was told about. Testing whether an app’s real assumptions hold, including sinks nobody wrote down, is the kind of work an autonomous researcher is built for, and you can read how we think about it on our about page. For more on the gap between pattern matching and reasoning about an app, see scanners vs research.

    Frequently asked questions

    What is the difference between a source and a sink?

    A source is any place attacker controlled data enters the program, such as a query parameter, a request header, a cookie, or an uploaded file. A sink is a place where that data is dangerous if attacker controlled, such as a SQL query, a shell command, an HTML response, or a file path. Injection bugs happen when data flows from a source to a sink without a sanitizer that fits that sink.

    What is a sanitizer and why is it sink specific?

    A sanitizer is code that cleans a value so it is safe for one specific sink, like parameter binding for SQL or HTML escaping for markup. It is sink specific because safe is a property of a value with respect to a single sink, not a property of the value on its own. A value HTML escaped for a page is still dangerous if you pass it to a shell, because shell metacharacters are not the ones HTML escaping handles.

    Why do taint tools start by listing sources and sinks?

    A tool cannot trace a flow until it knows what to trace from and what to trace to, so the catalog of sources and sinks is the starting configuration of every taint engine. That list sets the ceiling on what can ever be found. The propagation in the middle, carrying taint across variables and function calls, is a separate step.

    What happens if a sink is missing or miscategorized?

    A missing sink is not one missed bug, it is a whole class of them. If the tool does not know a call is dangerous, data can flow into it across the entire codebase and nothing is flagged. A miscategorized sink, such as a template render function labeled as a plain string call, makes the tool apply the wrong rules and miss the injection.


    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.

  • Data Flow Analysis Explained

    Data Flow Analysis Explained

    Most security bugs are really questions about where a value came from and where it ends up. Data flow analysis is the technique that answers those questions by hand off to a machine: it tracks how values move through a program and what facts hold true at each point in the code. This post teaches the general idea, walks a small example, and shows how one famous security check, taint tracking, is just data flow analysis with a specific fact.

    What data flow analysis actually tracks

    A program is a set of statements connected by control flow. Between any two statements there is a program point, a spot where you can pause and ask a question. Data flow analysis attaches a set of facts to every program point and works out which are true there. A fact is small and precise. Examples: “x holds a value assigned on line 3″, “count is definitely not zero here”, or “this string came from the network and nobody cleaned it”.

    The machinery does not run your code. It reads the structure, the control flow graph, and reasons about every path at once. That is why it can catch a branch your test suite never exercised. The method rests on two directions and two flavors of certainty.

    Forward and backward: which way the facts travel

    Some facts flow with the program, from the start toward the end. Some flow against it, from the end back toward the start. The direction you pick depends on the question.

    Reaching definitions (a forward analysis)

    A reaching definition asks: at this point, which assignments could have produced the current value of a variable? Facts start at the top of a function and travel down. Consider this:

    1  x = read_input()
    2  if flag:
    3      x = 0
    4  use(x)

    At line 4, two definitions of x reach the point: the one from line 1 and the one from line 3. If flag is false, the line 1 value survives. If it is true, line 3 overwrites it. Both reach line 4 because we do not know flag ahead of time. Reaching definitions run forward because a definition made earlier flows down into the code that uses it.

    Live variables (a backward analysis)

    A variable is live at a point if its current value gets read later before it is overwritten. The facts travel backward, from where variables get used toward where they get set.

    1  a = compute()
    2  b = compute()
    3  return b

    At line 1, is a live? Walk forward: line 3 returns b, and a is never read. So a is not live after line 1, and the assignment on line 1 is dead code you can delete. To learn that, the analysis starts at the return and pushes the fact “b is needed” backward up the function. Same graph, opposite direction.

    May versus must: two kinds of true

    Facts come with a strength. A may fact holds on at least one path into a point. A must fact holds on every path. That distinction decides what a tool is allowed to claim.

    • May analysis is for finding danger. “This value may be untrusted” means at least one path delivers dirty input, and one bad path is enough to be a bug. When paths meet, you take the union of their facts, so nothing dangerous gets dropped.
    • Must analysis is for proving safety. “This pointer must be non null here” has to survive every incoming path, or you cannot rely on it. When paths meet, you take the intersection, keeping only what every path agrees on.

    Security work leans on may analysis, because a vulnerability that shows up on one path in a thousand is still a vulnerability.

    The worklist: repeat until nothing changes

    How does a tool compute these facts across loops and branches? With a plain loop of its own, called the worklist algorithm. The idea is stubborn and simple.

    • Start every program point with an empty or default set of facts.
    • Put every node on a worklist.
    • Take a node off the list. Compute its facts from its neighbors, using the direction and the meet rule (union for may, intersection for must).
    • If that node’s facts changed, put its affected neighbors back on the list so they get recomputed.
    • Stop when the list is empty. Nothing changed, so you are done.

    That stopping state is called a fixed point: a set of facts so stable that running the rules again produces the same answer. Loops are the reason you need to iterate. The first pass through a loop body may add a fact that, on the second pass, changes what the loop entry sees, which changes the body again. The worklist keeps chasing those ripples until they settle.

    Data flow analysis is patient bookkeeping over the control flow graph: push facts along the edges, take the union or the intersection where paths meet, and repeat until the numbers stop moving.

    Walking one fact through a small function

    Let us track the reaching definition of total through a tiny function in an invented billing service called Acme Invoices.

    1  def price(items, discount):
    2      total = 0
    3      for item in items:
    4          total = total + item.cost
    5      if discount:
    6          total = total - 5
    7      return total

    Start the worklist. After line 2, the only definition of total is D2 (the assignment on line 2). Enter the loop. Line 4 defines total again, call it D4. Now here is the loop subtlety: at the top of the loop body, total could be D2 (first time in) or D4 (came back around). So the reaching set at line 4 becomes {D2, D4}. The first pass only knew {D2}, so the fact changed, so the worklist reruns the body. Second pass: same set {D2, D4}. No change. Fixed point reached inside the loop.

    At line 5, after the loop, total reaches as {D2, D4}: either the loop ran and D4 holds, or the list was empty and D2 survives. Line 6 adds D6 on the branch where discount is set. So at the return on line 7, the reaching definitions of total are {D4, D2, D6}, a may set, because different paths deliver different last writes. That short walk is the whole technique in miniature.

    The security payoff: taint is data flow analysis

    Now the connection. Pick one fact and make it the thing you track: “this value is untrusted”. Untrusted values enter at sources, like a request parameter or an uploaded file. Certain functions are dangerous sinks, like a database query or an HTML response. Run a forward, may style data flow analysis carrying the taint fact, and you learn every point where dirty data could arrive. If tainted data reaches a sink with no cleaning in between, you have a candidate injection bug.

    That applied pattern, source to sink, is its own subject with its own subtleties, so we treat it separately in source to sink data flow analysis. The one thing to hold onto here: taint tracking invents no new machinery. It is the worklist, the control flow graph, the may union, and the fixed point you just saw, with “untrusted” as the fact.

    These facts also need a place to live. A code property graph stores the control flow, the data flow, and the syntax in one queryable structure, which is what makes running these analyses at scale practical. For the broader argument about why understanding a program beats matching fixed patterns, see scanners versus research.

    At UnboundCompute we build on this kind of analysis to understand how an application is meant to work, then test where those assumptions break, which is a natural fit for a technique whose whole job is to follow where a value can go. You can read more about that on our about page.

    Frequently asked questions

    What is data flow analysis?

    Data flow analysis is a technique that tracks how values move through a program and what facts are true at each program point. It reads the control flow graph without running the code, attaches a set of facts to every point, and computes which facts hold there. Reaching definitions and live variables are classic examples, and taint tracking is the security application of the same idea.

    What is the difference between forward and backward data flow analysis?

    Forward analysis pushes facts along the program, from the start toward the end, which suits questions like reaching definitions, where an assignment flows down into the code that uses it. Backward analysis pushes facts against the program, from later uses toward earlier statements, which suits questions like live variables, where you look ahead to see if a value gets read before it is overwritten.

    What is the difference between may and must analysis?

    A may fact holds on at least one path into a program point, so it is used to find danger, since one bad path is enough to be a bug and paths meet with a union. A must fact holds on every path, so it is used to prove safety, and paths meet with an intersection that keeps only what all paths agree on. Security work leans on may analysis.

    How is taint tracking related to data flow analysis?

    Taint tracking is data flow analysis where the tracked fact is that a value is untrusted. Untrusted data enters at sources like a request parameter, and dangerous functions like a database query are sinks. A forward may style analysis carries the taint fact through the control flow graph, and if tainted data reaches a sink with no cleaning in between, you have a candidate injection bug.


    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.