Author: UnboundCompute

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

  • Control Flow Graphs Explained

    Control Flow Graphs Explained

    A control flow graph is the map an analyzer uses to reason about the order your code can run in. It splits a function into straight line chunks and draws arrows for every branch, loop, and return. Once you can read that map, a lot of security analysis stops looking like magic and starts looking like tracing arrows from the front door to a dangerous room.

    What a control flow graph actually is

    Take one function. A control flow graph breaks it into basic blocks and connects them with edges. A basic block is a run of straight line code with no jumps in the middle: execution enters at the top, runs every line in order, and leaves at the bottom. The moment the code can go two ways, an if, a loop test, an early return, that block ends and edges carry you to the possible next blocks.

    Every graph has one entry where the function starts and at least one exit where it ends. The edges encode the rule “after this block finishes, execution can go here.” That is the whole idea: not what the data means, just the order things can happen in.

    A small function and its blocks

    Here is a function that decides whether to charge a customer.

    def checkout(user, cart):        # B1
        if cart.is_empty():          # B1
            return "empty"           # B2
        total = price(cart)          # B3
        if user.has_discount:        # B3
            total = total * 0.9      # B4
        charge(user, total)          # B5
        return "ok"                  # B5

    The comments mark which basic block each line belongs to. The first block runs the empty check. If the cart is empty we jump to the return. Otherwise we compute a price, maybe apply a discount, then charge and finish. Drawn as a control flow graph it looks like this:

            [B1: enter, cart.is_empty?]
               /                  \
          true                    false
            |                       |
       [B2: return "empty"]   [B3: total=price; has_discount?]
            |                    /            \
            |                true            false
            |                 |                |
            |            [B4: total*=0.9]      |
            |                 \               /
            |                  \             /
            |                 [B5: charge; return "ok"]
            |                       |
             \                     /
              -----> [exit] <------

    Notice the shape. B1 forks into two edges because of the if. B4 is a small detour that only exists on one route. B3 always reaches B5, whether or not the discount block ran. Two blocks lead to the exit, B2 and B5, because the function has two ways to end.

    Paths: why the control flow graph matters for bugs

    A path is one full route from entry to exit. In the checkout graph there are three of them:

    • B1 -> B2 -> exit: the empty cart, nothing gets charged.
    • B1 -> B3 -> B5 -> exit: a normal customer, full price.
    • B1 -> B3 -> B4 -> B5 -> exit: a discount customer.

    This is the key point for security work. A bug often lives on only one path. Suppose the discount math had a rounding flaw that let total go negative. The empty cart path never touches it. The full price path never touches it. Only the third route, the one through B4, hits the bug. If your test data never included a discount user, you never ran the block where it lives.

    A vulnerability is not a property of a function. It is a property of a path through that function, and a scanner that never walks that path will swear the code is clean.

    Branches, loops, and returns as edges

    Three code shapes make almost every edge you will see:

    • Branches. An if, switch, or ternary splits one block into two or more outgoing edges, one per case.
    • Returns and breaks. A return, break, or continue ends a block early and sends an edge to the exit or back to a loop header, skipping whatever came after it.
    • Loops. A while or for adds an edge that points backward, from the end of the loop body up to the loop test. That back edge is what makes a graph a loop instead of a straight line.

    Why loops force analysis to approximate

    That back edge has a big consequence. Look at this:

    while queue:                 # L1
        item = queue.pop()       # L2
        process(item)            # L2

    How many paths run through here? If the loop can run zero times, once, twice, or a thousand times, then the number of distinct paths through the function grows without limit. A control flow graph with a single loop already describes an unbounded set of paths. You cannot list them all, so no analyzer tries to.

    Instead tools approximate. They summarize what is true no matter how many times the loop spins, rather than walking every possible count. That is why static analysis talks about what "can" reach a sink instead of exactly which run does. The graph is precise about order; the counting is deliberately loose because it has to be.

    Feasible and infeasible paths

    Not every route the graph allows can actually run. Consider two branches on the same flag:

    if admin:            # B1
        role = "root"    # B2
    if not admin:        # B3
        role = "guest"   # B4

    The graph draws an edge into B2 and an edge into B4. On paper a path exists that runs both. But admin cannot be true and false in the same call, so the route through B2 and B4 together is an infeasible path. It exists in the graph and can never exist at run time.

    A feasible path is one where the branch conditions along it can all hold at once. Telling the two apart is where analyzers earn their keep. Report a bug on an infeasible path and you have a false positive that wastes a developer's afternoon. Deciding feasibility in general is hard, so good tools reason carefully and still admit some uncertainty.

    Tying it back to security

    Most of the questions that matter for a vulnerability are control flow questions in disguise:

    • Is the check on every path? If an authorization check sits in B2 but one branch routes around it straight to the sensitive action, the graph shows the gap as an edge that skips the check.
    • Is the sink reachable only when a flag is set? A dangerous eval guarded by if debug_mode lives on a path that only opens when that flag is true. Whether an attacker can set the flag is the real question.
    • Does an early return leave state half done? A return edge that jumps out before a cleanup block runs is a missing step you can see in the graph.

    Here is a concrete miss. Imagine an API handler that validates ownership in one branch but has an early return for cached responses that skips straight to serving data. The cache path never touches the ownership check. Reading the code top to bottom, the check looks present. Reading the control flow graph, you see one edge that reaches the sink without passing through the guard. That single edge is the bug.

    Control flow answers "in what order can this run." The next question, "what value flows along this edge," is data flow, and it deserves its own treatment; this post stays on order of execution. If you want the reachability angle, whether a risky line can be reached at all under real conditions, see our piece on reachability analysis. And the control flow graph is only one layer of the bigger structure analyzers build, the code property graph, which stitches control flow, data flow, and syntax into one queryable model.

    Why this feeds better research

    Reading a control flow graph is the difference between a scanner that pattern matches on text and a researcher that understands which routes actually run, a theme we keep returning to in scanners vs research. UnboundCompute is an autonomous researcher built to reason about exactly these paths, asking not just whether a check exists but whether every route reaches it. Our about page explains how we think.

    Frequently asked questions

    What is a control flow graph?

    A control flow graph splits a function into basic blocks, which are runs of straight line code with no jumps in the middle, and connects them with edges for every branch, loop, and return. It models the order your code can run in, not the values it computes. Analyzers use it to reason about which routes through a function are possible.

    What is a basic block?

    A basic block is a straight line sequence of code with a single entry at the top and a single exit at the bottom. Execution runs every line in order with no branches in the middle. As soon as the code can go two ways, such as at an if or a loop test, the block ends and edges carry control to the possible next blocks.

    Why do loops make paths unbounded?

    A loop adds a back edge from the end of its body up to the loop test. If the loop can run zero, one, or many times, the number of distinct routes through the function grows without limit, so no analyzer lists them all. Instead tools approximate, summarizing what holds no matter how many times the loop spins.

    What is the difference between a feasible and an infeasible path?

    A feasible path is a route whose branch conditions can all hold at the same time, so it can actually run. An infeasible path exists in the graph but can never run, for example a route that requires a flag to be both true and false. Flagging a bug on an infeasible path produces a false positive, so telling the two apart matters.


    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.

  • What Is a Code Property Graph?

    What Is a Code Property Graph?

    A code property graph is one graph that merges three different views of a program so you can ask a security question and get a real answer. Instead of reading source as flat text, you query it like a map: where is this query, does this path run, and does an attacker controlled value actually reach it. This post explains what a code property graph is, why a single view of code misses bugs, and how a security query walks it across function calls.

    What a code property graph merges

    Source code can be modeled in several ways, and each way answers a different kind of question. A code property graph joins these views into one queryable structure so a single query can use all of them at once.

    • Abstract syntax tree (structure). The parse tree of the code. It knows this token is a function call, that token is a string literal, this block is the body of an if. It answers “what is this piece of code.”
    • Control flow graph (order of execution). Nodes are statements, edges are the order they can run in. It answers “can execution actually get here, and after this line, what runs next.”
    • Program dependence graph (data and control dependence). Edges connect a value where it is defined to every place it is used, and connect a statement to the condition that decides whether it runs. It answers “does this value flow into that spot, and what controls it.”

    Keep each view apart and you keep three partial pictures. Merge them onto shared nodes and one walk of the graph can see structure, order, and flow together.

    Why one view alone misses bugs

    Take a plain security question: can request input reach this SQL query. No single view answers it.

    • The syntax tree finds the query and finds where input enters, but it does not know if the value moves from one to the other, and it does not know if that line ever runs.
    • The control flow graph knows the path runs, but it treats a safe query and a dangerous one the same. It sees statements, not the meaning of the data inside them.
    • The dependence graph knows the value flows, but on its own it cannot tell you the sink is a SQL execution rather than a log line, because that is a fact about syntax.

    You need all three at once. Syntax to find the query and the input. Control flow to confirm the path is reachable. Dependence to prove the value actually lands in the query. A code property graph holds them together, so one query checks the whole claim instead of three tools guessing separately.

    A tainted input reaching a sink

    Here is a small example in an invented app called Acme Notes. A route reads a name from the request and builds a database query.

    def get_note(request):
        name = request.args.get("name")     # source: attacker controlled
        if request.method == "GET":
            q = "SELECT * FROM notes WHERE owner = '" + name + "'"
            return db.execute(q)            # sink: SQL execution
    

    Watch each layer of the code property graph do its part on this one snippet.

    • Syntax marks request.args.get as an input source and db.execute as a SQL sink. These are facts about what the nodes are.
    • Control flow shows that the db.execute line sits inside the if and does run on a GET request. The path is real, not dead code.
    • Dependence follows name into q through the string concatenation, then into the argument of db.execute. The tainted value reaches the sink with nothing cleaning it on the way.

    All three agree, so the query returns a true finding: attacker input reaches a SQL sink on a reachable path. Change one fact and the answer flips. Wrap name in a parameter binding and the dependence edge now runs through an escaping step, so the same query reports the flow as safe. This is the mechanism behind source to sink dataflow analysis.

    A single view of code can tell you a query exists. Only the merged graph can tell you an attacker controlled value reaches it on a path that runs.

    Walking a code property graph across function calls

    Real code does not keep the source and the sink in one function. The value crosses a call boundary, and the graph has to follow it. That is interprocedural analysis, and it is where a code property graph earns its keep.

    def handler(request):
        raw = request.args.get("name")   # source
        show_note(raw)
    
    def show_note(value):
        q = "SELECT * FROM notes WHERE owner = '" + value + "'"
        db.execute(q)                    # sink in a different function
    

    To connect the source in handler to the sink in show_note, the query walks a call edge, binds the actual argument raw to the parameter value, and continues the dependence walk inside the callee. The graph treats that argument to parameter binding as one more dependence edge, so the flow stays connected across the seam. Follow enough of these edges and you get a witness path from the request all the way to the query, even when it passes through several helpers. That stitched path is the heart of interprocedural taint analysis.

    The honest limits of a code property graph

    A code property graph is only as good as the parse it is built from. If the builder cannot resolve where a call goes, the edge it needs is missing, and a missing edge is not proof that no flow exists. A few cases are genuinely hard.

    • Dynamic dispatch. When the method called depends on the run time type of an object, the graph may not know which body executes, so it either guesses conservatively or misses the target.
    • Reflection. Calling a function by a string name, as with getattr(obj, name)(), hides the target from a static parse. The edge into the real callee simply is not there.
    • Function pointers. In C, a call through a pointer can reach any function whose signature fits, so the graph either over connects or under connects.

    Consider handler = ACTIONS[request.args.get("op")] followed by handler(data). A parser cannot see which function handler holds, so the call edge is unresolved. A serious tool marks that edge as conservative rather than pretending it does not exist, and a human reads the source to confirm. Honest tooling tells you where its map is solid and where it is guessing.

    How UnboundCompute uses the graph

    UnboundCompute builds a deterministic code property graph as its factual base, so a model reasons over real structure instead of guessing from raw text. The public graph engine is lachesis, and you can browse the rest of the stack from UnboundCompute on GitHub. The graph gives grounded facts about what the code is and how data moves, which is a different job than pattern matching over text. For more on that distinction, read scanners vs research.

    A code property graph does not find bugs by itself, but it is the map an autonomous researcher reads before it forms and tests an idea about where an app breaks. See what we are building on our about page.

    Frequently asked questions

    What is a code property graph?

    A code property graph is one graph that merges three views of a program: the abstract syntax tree for structure, the control flow graph for order of execution, and the program dependence graph for how data and control flow. Joining them on shared nodes lets a single query use structure, reachability, and data flow at once, which is why the engine behind it, such as lachesis, can answer real security questions.

    Why not just use an abstract syntax tree?

    An abstract syntax tree knows what each piece of code is, so it can find a SQL query and find where request input enters. It cannot tell you whether that line ever runs or whether the input actually flows into the query. You need the control flow graph for reachability and dependence edges for data flow, and a code property graph carries all three together.

    How does a code property graph work across function calls?

    When a value crosses a call boundary, the query walks the call edge and binds the actual argument to the parameter in the callee, then keeps following dependence edges inside that function. Chaining these edges produces a witness path from a source like a request parameter to a sink like db.execute, even through several helpers. This is the basis of interprocedural taint analysis.

    What are the limits of a code property graph?

    The graph is only as good as the parse it is built from. Dynamic dispatch, reflection such as calling a function by a string name, and C function pointers all hide the real call target from a static parse, so an edge may be missing or conservative. A missing edge is not proof that no flow exists, so honest tooling marks the guess and a human confirms it against the source.


    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.

  • Security Agent Skills: Testing Method, Not Another Scanner

    Security Agent Skills: Testing Method, Not Another Scanner

    Most security tooling ships as a scanner. You point it at a target, it runs a fixed set of checks, and it hands you a list. Security agent skills take the opposite shape. Instead of packaging the checks, they package the tester’s method: the order you look at things, the way you decide a lead is real, and the discipline that keeps you from calling a guess a finding. A skill is a short playbook an AI agent reads and follows, and its value is judgment, not a script.

    What a security agent skill is

    A skill is a small file with a name, a description of when to use it, and a body that walks an agent through one class of security work. The agent loads it the moment the task matches, the same way it loads any other instruction. The body does not tell the agent to run a specific product. It names the capability a step needs, for example “something that can answer who calls this function from a real parse,” and leaves the tool choice to whoever runs it.

    That framing matters because the reasoning is the part that transfers. A scanner’s rules go stale and are tied to one engine. A method survives a rename, a new framework, and a different toolchain, because it describes how to think about a bug, not how to grep for one.

    Why method beats a script

    Anyone who has triaged scanner output knows the failure mode. The tool floods you with matches, ranks them by a number that does not mean much, and misses the bugs that need a chain of two or three steps to see. The reason is simple. A rule fires on a shape. A vulnerability is a shape plus a context, and the context is where the judgment lives.

    A rank is triage, not a filter. A lead is a fact, never a verdict. You confirm it against the real code, or you kill it and write down why.

    So every skill in the library is built on the same spine. Enumerate the whole taxonomy before you look at one family, so you do not tunnel on the first idea. Read a lead as evidence, not as a conclusion. Prove it with a path from source to sink, or a request that actually returns another user’s data, before you write it up. This is the same reasoning behind how experienced testers find vulnerabilities, and the same reason static analysis misses business logic.

    What the library covers

    The open collection spans five lanes, from white box code review to black box testing to red teaming an AI agent. A few of the areas it goes deep on:

    AI agent and LLM red teaming

    • The lethal trifecta. Where private data, untrusted content, and an exfiltration path meet in one agent context. Read the pattern in the lethal trifecta.
    • Indirect prompt injection. Whether an agent obeys instructions hidden in content it ingests. Background in indirect prompt injection.
    • MCP tool integrations. Tool poisoning, shadowing, and metadata that runs before consent. See MCP tool poisoning.
    • Memory and retrieval poisoning. A poisoned index or memory that fires on an innocent query. See RAG data poisoning.
    • Excessive agency. Missing approval gates, open egress, and denial of wallet. See excessive agency and least privilege for agent tools.

    White box bug hunting

    • Taint adjudication. Turn a source to sink lead into a confirmed finding or a documented kill. The method behind source to sink dataflow analysis.
    • Guard gaps. Find the unguarded peer of a function that is checked everywhere else.
    • Business logic. Step skipping, limit overrun, and replay that scanners never see. Background in business logic vulnerabilities.
    • Fail open controls. A gate that allows on error, on an empty list, or on missing input. See fail open access control.

    Web and access control depth

    • Broken object level authorization. Reaching another user’s record through an id you should not control. See IDOR.
    • Server side request forgery. Proving SSRF to internal reach and instance credentials. See SSRF.
    • Request smuggling and cache attacks. Front end and back end desync, and the key versus response gap. See request smuggling and cache poisoning.

    How a single skill runs

    Each skill follows the same loop, so an agent behaves the same way whether it is reviewing code or probing an endpoint.

    • Scope. Confirm the work is authorized. If you cannot name the authorization, you stop.
    • Orient. Map the surface for this class of bug before touching one instance.
    • Enumerate. List every candidate in the family, not just the first that catches your eye.
    • Adjudicate. For each lead, read the provenance and decide. Confirm with a real path or a real request, or kill it and record the reason.
    • Report. Emit the survivors in one shared finding schema, so results from any skill are consistent and ready to write up.

    The kill step is the part that separates a method from a scanner. A skill is judged as much by the leads it throws away with a clear reason as by the bugs it keeps.

    How to use it

    The collection is open source under the MIT license, so you can read every skill before you run it. There are three ways to pick it up:

    • As a plugin. Install the whole set into an agent that supports the skill format, and it routes to the right skill by name and description.
    • One skill by hand. Copy a single skill folder into your own skills path when you only want one class of check.
    • Any agent, by hand. The skill bodies are standalone playbooks. Read one and follow the loop yourself, running whatever tools you already have at each step.

    The code, the full skill list, and the shared finding schema live in the security-agent-skills repository on GitHub.

    The takeaway

    A scanner encodes a list of known shapes. A skill encodes how a good tester reasons about a shape in context, which is the part that finds the bug two steps deep and throws away the noise. That is why the library leads with method and stays tool agnostic. Bring your own tools; keep the judgment.

    This is the same principle UnboundCompute is built on. An autonomous researcher that studies how an application is meant to work, forms ideas about where that logic breaks, and proves a finding with hard evidence before reporting it. In early work, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. You can read more on the about page.

    For the AI agent side of this method, the AI Agent Security Field Guide maps how agents get attacked and how to defend each case.

    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.

  • AI SAST vs Traditional SAST

    AI SAST vs Traditional SAST

    Static analysis has read source code for decades, and it is good at what it does. But a new phrase keeps coming up in security tooling: ai sast. This post is an honest look at how AI assisted static analysis differs from the traditional rule based kind, what each is good at, and why the sound version of the AI approach keeps a deterministic graph as its source of truth instead of trusting the model to invent facts.

    What traditional SAST does

    Traditional SAST parses your code and looks for known dangerous shapes in it. It traces a value from where it enters, such as a request parameter, to where it lands, such as a database query. If tainted input reaches a sink without passing through something the tool recognizes as a cleaner, it raises a finding.

    Here is the kind of flow it catches well:

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

    The user controls name, it lands in a SQL string with no escaping, and the rule fires. This is fast, deterministic, and repeatable. Run it twice on the same code and you get the same answer. Run it on every commit and it catches obvious sink bugs before they ship.

    Where rules run out

    The trouble is that a rule does not know what your code is for. It matches patterns in the text, so it is strong on injection where input reaches a sink, and weak on anything with no fixed pattern. Two problems follow:

    • It misses logic bugs. A missing ownership check on GET /api/invoices/42 is clean code. There is no tainted value, no dangerous sink, nothing for a rule to grab. So the rule stays quiet on one of the most common serious bugs in real apps.
    • It produces many false positives. When a value was validated in a way the tool does not model, or the path is dead code, the rule cannot tell. To avoid missing a real bug it flags the maybe, and a developer spends time ruling it out. A backlog of noise trains teams to ignore the tool.

    What ai sast adds

    AI SAST puts a model on top of that picture. Instead of only matching patterns, a model can read code the way a person would. It can reason about what a function is meant to do, follow a flow that is too indirect for a fixed rule, and judge whether a candidate finding is actually reachable and harmful.

    Take an access control example that rules miss. Say a notes app serves a note by id:

    GET /api/notes/4471
    Authorization: Bearer <user A token>

    A rule checks the response is well formed and moves on, because nothing in the request looks malicious. A model reading the handler notices the id is a plain number from the URL, and that the query loads the note by id with no filter on the current user. It reasons about intent: a note is private, so the server should check who owns it, and this handler does not. That is an insecure direct object reference, and it has no payload and no pattern, which is why a rule never sees it.

    The same reasoning catches a fail open check. Imagine an authorization function that returns True when a role lookup succeeds, but also returns True in the except branch when the lookup throws, so any error grants access. A rule matching known sinks walks right past it. A model reading the two branches can see that the error path opens the door instead of closing it.

    The honest catch with models

    A model on its own is not a safe security tool. It is nondeterministic, so it can give two different answers on the same file. It can hallucinate a call that does not exist or a flow that never connects. If you let a model roam a whole repository and report whatever it believes, you trade the known noise of rules for a new, less predictable kind.

    A model is good at understanding intent and bad at being sure. Rules are sure and blind to intent. The useful design uses each for the half it is good at.

    Pairing facts with understanding

    The way to get the upside without the hallucination is to give the model hard ground to stand on: three parts working together.

    • A deterministic code property graph for facts. A real parse of the code builds the call and dataflow graph: what calls what, what a value can flow into, what a pointer can reference. These edges come from a compiler grade parse, not from a guess. The public lachesis code property graph is this layer for us, the source understanding that the rest reasons over.
    • A model for understanding and hypothesis. The model reads bounded, connected regions of that graph, not the whole repository at once. It asks the questions a rule cannot: what is this function meant to protect, does this check hold on every path, could this id belong to another account. It forms a hypothesis about a bug.
    • Verification before reporting. A hypothesis is not a finding. The claim is checked against evidence before it goes in the report, so the output is proven rather than guessed.

    The key move is that the model never invents edges. The graph is the source of truth for what connects to what, and the model reasons over regions of it. If the model believes a value flows from a request into a query, that path either exists in the deterministic graph or it does not. Understanding proposes, facts constrain, verification confirms. That is what keeps AI assisted analysis defensible instead of a stream of plausible sounding guesses.

    Why bounded regions matter

    Reasoning over a bounded region keeps the model focused on code that is actually connected, so it is not inventing links across unrelated files. It also makes the reasoning checkable, because the region and its graph edges are concrete. You can point at the exact functions the conclusion rests on.

    How the two approaches compare

    • How it finds bugs. Traditional SAST matches known patterns and taint rules. AI SAST reasons about what code is meant to do and forms a hypothesis.
    • Determinism. Rules are repeatable and give the same answer every run. Models are nondeterministic, which is why the sound design grounds them in a fixed graph.
    • Logic bugs. Rules miss access control and fail open flaws that have no pattern. Understanding plus verification reaches them.
    • False positives. Rules flag maybes to stay safe. Verification before reporting removes the guesses the model would otherwise pass on.
    • Trust. A rule finding is a candidate to triage. A verified finding is backed by evidence you can read.

    We go deeper on this split in scanners vs research, and if you want the wider tool landscape first, our SAST vs DAST vs IAST post lays it out.

    Where this sits honestly

    None of this makes traditional SAST useless. It is fast, cheap, and good at sweeping for the known sink bugs, so keep running it. The point is that pattern matching has a ceiling, and the highest impact bugs live above it, in the assumptions code makes about who you are and what you are allowed to do. AI assisted analysis reaches those, but only when the model is grounded in facts and its claims are proven.

    That grounding is the design UnboundCompute is built on: a deterministic graph for facts, a model to reason over bounded regions of it, and proof before anything is reported. The graph layer is open, and you can read the code as UnboundCompute on GitHub. We are early and still building, so we describe the approach rather than sell a result. A fuller walkthrough lives in how UnboundCompute works, and you can read where we are headed on our about page.

    Frequently asked questions

    What is the difference between AI SAST and traditional SAST?

    Traditional SAST matches known dangerous patterns and taint rules over your source code. It is fast, deterministic, and repeatable, but it does not understand what the code is for, so it misses logic bugs and raises many false positives. AI SAST adds a model that reads code the way a person would, reasons about what a function is meant to do, follows less obvious flows, and judges whether a finding is real.

    Can AI SAST catch bugs that rule based SAST misses?

    Yes, especially bugs with no fixed pattern. A missing ownership check on a request like GET /api/notes/4471 is clean, well formed code with no dangerous sink, so a rule stays quiet. A model reading the handler can reason that a private note should be filtered by the current user and flag the insecure direct object reference. The same reasoning catches a fail open authorization check that grants access in an error branch.

    Does AI SAST hallucinate findings?

    A model on its own can. It is nondeterministic and can invent a call or a flow that does not exist. The sound design avoids this by keeping a deterministic code property graph as the source of truth for what connects to what, letting the model reason only over bounded connected regions of that graph, and verifying a claim against evidence before reporting it. The model proposes, the facts constrain, and verification confirms.

    Should I replace traditional SAST with AI SAST?

    No, keep running traditional SAST. It is fast, cheap, and good at sweeping for known sink bugs on every commit. AI assisted analysis reaches the logic and access control flaws that have no pattern, but only when the model is grounded in a real graph and its findings are proven rather than guessed. The two approaches cover different halves of the problem.


    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.

    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.

  • Why SAST Tools Have So Many False Positives

    Why SAST Tools Have So Many False Positives

    If you have ever turned on a static scanner and watched it dump hundreds of alerts on the first run, you have met the problem this post is about. Most of those alerts are wrong, and the reasons are not random. This post walks through why sast false positives happen, gives a short code example for each cause, and explains what actually cuts the noise so a real bug does not drown in it.

    What SAST does well, and where sast false positives creep in

    SAST reads your source code without running it. It builds a model of how data moves and flags a path when tainted input looks like it reaches a dangerous function. That is genuinely useful. It catches obvious injection sinks early, on every commit, before code ships, and it sees branches that live traffic rarely touches. The trouble is that a static reader has to guess about everything the program would only know at run time. Each guess it gets wrong becomes a false positive. Here are the five causes that produce most of them.

    Cause one: no reachability

    The scanner sees a sink and flags it even when no attacker input can get there, or when the code never runs at all. It matches the shape of a bug without proving the path.

    def build_report(rows):
        q = "SELECT * FROM audit WHERE id = " + rows[0].id
        db.execute(q)

    That string concatenation into a query looks like SQL injection. But rows comes from an internal report job, and id is an integer the database itself assigned. No user ever controls it. The value is not attacker reachable, so the finding is noise. A tool that only pattern matches the sink cannot tell the difference between this and a real one.

    Cause two: unmodeled sanitizers

    Teams write their own validation and escaping helpers. If the scanner does not know your function cleans the data, it treats the value as still tainted all the way to the sink.

    def handler(request):
        name = request.args.get("name")
        safe = clean_identifier(name)   # keeps only a to z and 0 to 9
        db.execute("SELECT * FROM t WHERE u = '" + safe + "'")

    Here clean_identifier keeps only letters and digits, so the value reaching the query is already safe. The scanner has a built in list of sanitizers it trusts. Your custom one is not on that list, so it reports tainted input flowing into a query. The code is fine. The model is incomplete.

    Cause three: over approximation

    Static analysis has to be conservative. When it cannot decide something, it assumes the worst so it does not miss a real bug. That safety costs precision at every branch and every dynamic call.

    def render(kind, value):
        fn = RENDERERS.get(kind, escape_html)
        return fn(value)

    The scanner cannot always tell which function fn will be at run time. To stay safe it assumes fn could be one that does not escape, so it flags a possible cross site scripting path. In practice every entry in RENDERERS escapes its input. The tool reports the worst case because it cannot resolve the call, and worst case reporting piles up fast in code that uses dictionaries of handlers, callbacks, or reflection.

    Cause four: config and framework blindness

    A lot of safety lives in a layer the parser does not model. A query behind a parameterized driver or an ORM looks like raw string handling to a tool reading text.

    User.objects.raw(
        "SELECT * FROM users WHERE email = %s", [email]
    )

    The %s here is a bound parameter. The database driver sends the query and the value separately, so email is never interpreted as SQL. To a scanner that does not understand this ORM method, it reads like a hand built query string with user input in it, and out comes an injection alert. Framework routing, template autoescaping, and middleware that validates input all cause the same blind spot. If you want the mechanics of the real bug this one imitates, see what is SQL injection.

    Cause five: dead code and test fixtures

    Scanners read every file, including code that never ships and test data written to be deliberately unsafe.

    # tests/fixtures.py
    BAD_PASSWORD = "hunter2"
    def make_vulnerable_query(x):
        return "SELECT * FROM t WHERE a = '" + x + "'"

    This is a fixture. It exists to test the scanner or to seed a demo, and nothing in production calls it. The tool cannot tell a hardcoded secret in a test from one in a live config, or a deliberately unsafe helper from a shipping one, so it reports both. An old function no one calls anymore lands in the same bucket.

    The real cost of a noisy backlog

    Every wrong alert is developer time. Someone opens it, reads the code, proves it is safe, and closes it. Do that a few hundred times and the lesson the team learns is that the tool cries wolf. Once people stop reading the output, the one true positive sitting in the pile gets closed with the rest.

    A scanner that reports maybes trains a team to ignore it, and the day it is finally right, no one is listening.

    This is the quiet failure mode of static analysis. The tool did find the real bug. It also found four hundred fake ones around it, so the real one was never seen. Precision is not a nice to have. Below a certain signal level, a tool stops changing what anyone does.

    What actually reduces sast false positives

    The fixes all point the same way: stop reporting shapes and start proving paths.

    • Reachability analysis. Before flagging a sink, check that attacker controlled input can actually reach it and that the code runs. The build_report case above disappears the moment you ask whether any real input flows in.
    • Model the sanitizers and framework. Learn the custom cleaning functions and the ORM and template layers a codebase uses, so a parameterized query stops reading as raw string building.
    • Confirm the value truly reaches the sink. Trace the specific value, through the specific branches, and only report when the path holds end to end. A maybe is not a finding.
    • Separate test and dead code from live code. Fixtures and uncalled functions are not production risk and should not sit in the same queue.

    The common thread is confirmation. A finding you can defend to a hostile expert is one where you can point at the source, the sink, and the path between them, and show input moving along it. Everything short of that is a guess dressed up as an alert.

    This is the gap we care about at UnboundCompute: it confirms a real source to sink path and proves a finding before reporting it, so the output is signal instead of a list of maybes. That difference between pattern matching and proof is what our writing on scanners vs research keeps coming back to, and it is why we compare tools like SAST, DAST, and IAST by how well they prove a bug, not how many they claim. You can read how we think about it on our about page.

    Frequently asked questions

    Why does SAST produce so many false positives?

    Because a static tool reads code without running it, so it has to guess about things only known at run time. It flags a sink even when no attacker input reaches it, treats custom cleaning functions as unsafe because it does not recognize them, assumes the worst at dynamic calls, misreads parameterized queries behind an ORM as raw strings, and reports test fixtures and dead code as live risk.

    What is reachability analysis and why does it reduce sast false positives?

    Reachability analysis checks that attacker controlled input can actually reach a sink and that the code runs, before reporting anything. Many false positives are sinks that look dangerous but no real input flows into, or code that is never called. Confirming the path first removes those maybes and leaves findings you can defend.

    Are all SAST findings false positives?

    No. SAST genuinely catches obvious injection sinks, hardcoded secrets, and unsafe patterns early on every commit, including on branches live traffic rarely touches. The problem is the ratio. A real finding is often buried under hundreds of wrong ones, so the useful signal gets ignored along with the noise.

    How do I reduce false positives from my SAST tool?

    Model the custom sanitizers and the ORM and template layers your codebase uses so safe patterns stop reading as raw, separate test and dead code from production code, and prefer tools that confirm a value truly reaches the sink before reporting instead of matching the shape of a bug. The common thread is proving the path rather than flagging a pattern.


    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.

    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.

  • Why SAST Misses Business Logic Bugs

    Why SAST Misses Business Logic Bugs

    Static analysis is good at finding bugs that have a shape. Feed it your source code and it will spot a SQL string built from user input or a password committed straight into a file. But the moment you ask about sast business logic coverage, the story changes, because the worst logic bugs have no bad shape to match. The code is clean, it does exactly what it says, and it is still wrong.

    What SAST is actually good at

    SAST (static application security testing) reads your code without running it and traces how data moves. It follows a value from where it enters, like a request parameter, to where it gets used, like a database call. When tainted input reaches a dangerous function with no cleaning in between, it flags the path.

    Here is the kind of code it catches every time:

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

    The user controls name, it lands in a raw SQL string, and the scanner follows that line from source to sink. The same works for command injection, unsafe deserialization, weak crypto calls, and hardcoded secrets. All of these share one trait: there is a recognizable pattern in the text. A concatenated query looks dangerous. A private key in a string literal looks dangerous. SAST is a pattern engine, and these are patterns, so it does well.

    Why sast business logic coverage falls apart

    Business logic bugs do not have a pattern. The code is syntactically clean and does what the developer wrote. The problem is not a dangerous line that is present. The problem is a safe line that is missing, and absence of code has no shape for a matcher to find.

    Walk through four examples. Each one passes every static check and is still a real hole.

    An IDOR with a perfectly safe query

    Consider this handler in an invented invoicing app called Acme Billing:

    @app.get("/api/invoices/<int:invoice_id>")
    def get_invoice(invoice_id):
        row = db.execute(
            "SELECT * FROM invoices WHERE id = ?", (invoice_id,)
        ).fetchone()
        return jsonify(row)

    SAST loves this code. The query is parameterized, so there is no injection. The input is an integer, so the type check passes. Nothing here matches a bad pattern. But a user who owns invoice 41 can request GET /api/invoices/42 and read someone else’s invoice, because the handler never checks who owns the row. This is an insecure direct object reference, a form of business logic vulnerability. The missing line is an ownership check, and a missing line has no signature.

    A negative quantity that breaks an invariant

    A checkout endpoint accepts a quantity and multiplies it by a price:

    total = item.price * request.json["quantity"]
    account.balance += total

    Every type check passes. The quantity is an integer, the price is a number, the math is valid. Send {"quantity": -3} and the total goes negative, so the buyer gets credited instead of charged. The rule the app assumed, that quantity is always positive, is nowhere in the code. SAST cannot flag a broken invariant it was never told about.

    A refund flow you can replay

    A refund endpoint marks an order refunded and pays the customer. It reads the order, issues the payment, then writes the status. If two requests arrive at once, or the same request is sent twice, both can pass the status read before either writes it, and the customer gets paid twice. The code is clean. There is no injection, no bad function call. The flaw is a missing lock and a missing idempotency key, and again the bug is what is not there.

    A workflow step you can skip

    A signup has three steps: verify email, accept terms, then activate. The activate route trusts that the first two ran. Nothing stops a caller from posting straight to POST /activate and skipping ahead. The state machine lives in the developer’s head, not in a shape the scanner can read.

    The root reason SAST cannot see these

    Put the four together and the pattern is that there is no pattern. SAST knows how to spot dangerous shapes. It does not know what your app is supposed to do. It has no idea that invoice 42 belongs to a different user, that quantity must be positive, that a refund should happen once, or that activation comes last. Those are rules about intent, and intent is not written in the syntax.

    A static scanner can prove your query is safe. It cannot prove your app enforced the rule you never wrote down.

    This is why the fair contrast is not people versus tools. It is pattern matching versus understanding. Signature based static analysis asks one question: does this code contain a known bad shape? A logic bug answers no, honestly, and slips through. To be clear, this is not a reason to drop SAST. It catches real injection and secret bugs early and cheaply on every commit, and that is worth keeping. It just has a ceiling, and business logic sits above it.

    What closes the gap

    The fix is not a longer list of bad patterns. You cannot write a signature for a check that should exist but does not. What closes the gap is modeling what the app is meant to do and then testing whether its assumptions hold.

    • Learn the intended rules. Ownership, allowed value ranges, once only actions, required order of steps. These are the invariants the code quietly trusts.
    • Form ideas about where they break. If invoice reads are keyed by id, ask whether id is checked against the caller. If a refund writes state after paying, ask what happens on a replay.
    • Test the assumption, not a payload. Send GET /api/invoices/42 as the wrong user and see if a real invoice comes back. Replay the refund and see if the balance moves twice.

    That is reasoning about the app, not scanning it for shapes. If you want to go deeper on where pattern tools stop and this kind of work begins, read our writing in scanners vs research, and the tool comparison in SAST vs DAST vs IAST.

    UnboundCompute is built for exactly this gap. It learns how an app is meant to work, forms ideas about where that logic breaks, and tests those assumptions instead of matching a fixed payload list. You can read how we think about it on our about page.

    Frequently asked questions

    Why does SAST miss business logic bugs?

    SAST is a pattern engine. It finds code that has a dangerous shape, like a SQL string built from user input or a secret in a literal. Business logic bugs have no bad shape. The code is clean and does what it says, and the flaw is a missing check the scanner was never told to expect. Absence of code has no pattern to match.

    What kinds of bugs does SAST find well?

    SAST is strong on bugs with a recognizable pattern in the source: SQL and command injection where tainted input reaches a sink, unsafe deserialization, weak crypto calls, and hardcoded secrets. These all share a visible signature in the text of the program, so a static matcher can trace and flag them early on every commit.

    Can SAST catch an IDOR or broken access control?

    Usually no. Consider GET /api/invoices/42 handled by a parameterized query with an integer id. There is no injection and the type check passes, so SAST sees clean code. The bug is the missing ownership check, and a missing line has no signature for a static scanner to find.

    What actually finds business logic bugs?

    Understanding, not pattern matching. You model what the app is meant to do, its ownership rules, value ranges, once only actions, and required step order, then test whether those assumptions hold. That means sending a request as the wrong user or replaying a refund and checking the result, rather than matching a fixed payload list.


    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.

    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.