Category: Scanners vs Research

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

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

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

  • Reachability Analysis and Whether a Bug Is Exploitable

    Reachability Analysis and Whether a Bug Is Exploitable

    A scanner flags a scary function or a dependency with a known CVE, and the ticket lands on your desk. Before you spend an afternoon on it, ask the one question that decides whether it matters: is there a real path from attacker controlled input to that code in this app. That question is what reachability analysis answers, and it is the difference between a finding you must fix now and a line item you can safely park.

    What reachability analysis actually asks

    Most scanners work by matching. They see that you imported yaml.load, or that your lockfile pins a version of a library with a published advisory, and they raise an alert. Matching tells you the ingredient is in the kitchen. It does not tell you whether anyone cooks with it, or whether a stranger gets to choose what goes in the pot.

    Reachability analysis splits into two questions that both have to be true for a bug to be exploitable:

    • Call reachability. Is the dangerous function ever called on a path that begins at an entry point, such as an HTTP route, a queue consumer, or a CLI command an attacker can trigger. If nothing calls it from the outside, an attacker cannot make it run.
    • Taint reachability. Even when the function does run, does attacker controlled data actually arrive at the dangerous parameter. A function is only unsafe if the value it trusts is a value the attacker gets to set.

    A finding needs both. Call reachability without taint means the code runs but only ever on values you chose. Taint without call reachability means the data could flow in theory, but no real path fires the function. Only when both hold do you have something an attacker can steer.

    A concrete example: same function, two very different findings

    Say your dependency scanner flags libparse.render(template, data) in a fictional library, because that function has a known server side template injection bug. It appears in two places in an invented app called Acme Notes. The scanner raises the same alert for both. Reachability tells them apart.

    Here is the first call site:

    WELCOME = "Hello, welcome to Acme Notes"
    
    def startup_banner():
        return libparse.render(WELCOME, {})

    The template argument is a hard coded constant. No route reaches startup_banner with any external input, and the value it renders can never be anything but that fixed string. An attacker has no lever here. This is present, but not reachable. It is a theoretical finding, not an exploitable one.

    Here is the second call site:

    @app.post("/notes/preview")
    def preview():
        body = request.get_json()
        tpl = body["template"]
        return libparse.render(tpl, current_user_context())

    Now the template comes straight from the request body. There is a live route, so the function is call reachable. The dangerous parameter receives attacker controlled data, so it is taint reachable. A request like POST /notes/preview with {"template": "{{ 7*7 }}"} proves the path. Same library, same function, same CVE. One call site is noise and the other is a real bug. The only thing that separates them is reachability.

    Why reachability is the main lever against false positives

    Alert fatigue is not caused by tools finding too few bugs. It is caused by tools reporting every match as if it were exploitable. A team that gets a hundred dependency alerts, where ninety of them sit behind code no request ever reaches, learns to ignore the list. Then the real one hides in the pile.

    A vulnerable function that no attacker input can reach is a fact about your dependencies, not a hole in your app. Treating the two the same is how a real bug drowns in a sea of maybes.

    Reachability changes the shape of the work. Instead of triaging a hundred matches by hand, you spend time only on the findings where a path from an entry point to the dangerous line has been shown to exist. The rest are worth patching on a normal upgrade schedule, but they are not incidents. This is the same idea we write about in why we only report proven vulnerabilities: a report should carry evidence, not a guess. It also sits next to the tradeoffs in SAST vs DAST vs IAST, where each tool is noisy for its own reason.

    How reachability gets computed

    To answer these questions without running every possible input, you model the program as a graph. Functions and statements become nodes, calls become edges from caller to callee, and data flow becomes edges from where a value is produced to where it is used. This is often called a code property graph, because it holds the call structure and the data flow in one place.

    Call reachability is then a path search. Start at each entry point, walk the call edges, and see if you can arrive at the dangerous function. Taint reachability is a second search over the data flow edges: start at the attacker controlled source, follow assignments and passes through parameters, and see if the value lands on the dangerous parameter without being sanitized on the way. When both searches connect, you have a witness path, a concrete route from input to sink that a human can read and check. The open code property graph we use to compute reachability is lachesis on GitHub, if you want to see the shape of the data these searches run over.

    The honest limits: what a graph cannot see

    Reachability is strong, but it is not omniscient, and pretending otherwise is how you get a new kind of false result. Several common patterns hide edges from static analysis:

    • Dynamic dispatch. When the target of a call is chosen at run time, through a method on an object whose real type is not obvious, the graph may not know which function actually runs.
    • Reflection. Code that calls a function by looking its name up from a string, with things like getattr or an eval style call, is invisible to a plain call edge. The name might come from config, or from the request itself.
    • Config driven wiring. Frameworks that connect routes to handlers, or plug in middleware, through a config file or a registry decide part of the call graph outside the code the analyzer reads.

    Here is why that matters. Suppose a route dispatches to a handler chosen by a string in the URL:

    handler = HANDLERS.get(request.path_param("action"))
    handler(request.body)

    A static walk of the call edges cannot be sure which handler runs, so it cannot say for certain whether the dangerous function is reached. The honest move is not to guess clean and not to guess vulnerable. The honest move is to mark that edge as uncertain, and to say so plainly in the finding.

    Reachability analysis in practice: prove before you report

    Put it together with the Acme Notes example. Two alerts came in for the same template function. One had a hard coded constant and no route, so no path from an attacker existed and it stayed off the incident list. The other had a request body flowing into the template through a live route, so a full path from source to sink was shown, and that one got fixed the same day. Reachability did the sorting, and the witness path made the second finding impossible to argue with.

    This is the approach UnboundCompute takes. It computes source to sink reachability over a deterministic graph and reports a finding only after a real path is confirmed, and it marks unresolved dynamic edges as uncertainty rather than asserting reach it cannot back up. If you want the longer version of how we think about signal over noise, read more on our about page, and browse the rest of scanners vs research.

    Frequently asked questions

    What is reachability analysis in application security?

    Reachability analysis asks whether a flagged function or vulnerable dependency can actually be reached by an attacker in this specific app. It has two parts: call reachability, meaning some entry point like an HTTP route leads to the dangerous function, and taint reachability, meaning attacker controlled data actually arrives at the dangerous parameter. A finding is exploitable only when both are true.

    Why does a vulnerable library not always mean my app is vulnerable?

    A dependency can carry a known bug in a function your code never calls, or only ever calls with a hard coded constant the attacker cannot change. In that case the vulnerable code is present but not reachable from any attacker input, so there is no path to exploit. It is worth patching on a normal upgrade schedule, but it is not an incident.

    How does reachability reduce false positives and alert fatigue?

    Scanners raise an alert for every match, and most matches sit behind code no request ever reaches. Reachability lets you spend time only on findings where a real path from an entry point to the dangerous line has been shown to exist. That turns a long list of maybes into a short list of confirmed, exploitable issues, so the real bug stops hiding in the noise.

    What are the limits of reachability analysis?

    Dynamic dispatch, reflection through things like getattr, and config driven wiring can hide call edges from static analysis, so some paths cannot be resolved with certainty. The honest response is to mark those edges as uncertain rather than claiming the code is clean or vulnerable. UnboundCompute reports a finding only after a real path is confirmed and flags unresolved dynamic edges as uncertainty.


    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.

  • Interprocedural Taint Analysis Explained

    Interprocedural Taint Analysis Explained

    Most code that matters is spread across many functions and many files. That is why interprocedural taint analysis is the difference between an analyzer that confirms a real path from user input to a dangerous sink and one that flags every sink and hopes you sort it out. This post explains what the term means, contrasts it with tracking taint inside a single function, and is honest about the hard parts that make it fail.

    What interprocedural taint analysis means

    Taint analysis follows untrusted data. A source is where attacker controlled input enters, like a request parameter. A sink is where that data becomes dangerous, like a SQL string or a shell command. Taint analysis tracks the value from source to sink and reports when it arrives without being cleaned.

    The word interprocedural describes how far the tracking reaches. There are two levels:

    • Intraprocedural analysis stays inside one function. It sees the variables in that function and nothing else. When the value is handed to another function, the trail goes cold.
    • Interprocedural analysis follows the value out of the function. It tracks the value as it is passed in as an argument, returned back to a caller, thrown as an exception, and carried across file and module boundaries.

    Real bugs live between functions. A handler reads the input, hands it to a helper, that helper calls a database wrapper, and the unsafe query runs three functions away from where the input arrived. If your analysis stops at the first function, you never see the end of that chain.

    Intraprocedural is easy and mostly wrong on its own

    Here is a flow an intraprocedural analyzer handles well, all in one function.

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

    The source (request.args.get) and the sink (db.execute) are in the same function. Simple tracking inside search catches it. This is the demo every tool passes.

    Now split it the way real code is written. The input is read in one file and the query runs in another.

    # file: handlers/users.py
    from db.helpers import run_lookup
    
    def search(request):
        name = request.args.get("name")   # source
        return run_lookup(name)           # tainted value leaves this function
    # file: db/helpers.py
    def run_lookup(term):
        query = "SELECT * FROM users WHERE name = '" + term + "'"
        return db.execute(query)          # sink, in a different file

    Nothing dangerous happens inside search. Nothing obviously tainted happens inside run_lookup either, if you read it alone, because term is just a parameter with no visible origin. An analyzer that looks at one function at a time sees two clean functions and reports nothing. The bug is exactly the kind of SQL injection that ships to production.

    How interprocedural taint analysis follows the value

    To catch that flow, the analyzer needs to connect the two functions. It does that by modeling the call as a set of edges between the caller and the callee. Four kinds of edges carry taint across a call:

    • Actual to formal. The argument at the call site (the actual parameter) binds to the parameter name inside the callee (the formal parameter). Here name maps to term, so taint on name becomes taint on term.
    • Return. Whatever the callee returns flows back to the value the caller assigned. If a helper returns tainted data, the caller now holds tainted data.
    • Throw. A tainted value can leave a function through a raised exception, so the edge to the handler that catches it has to carry taint too.
    • Formal to actual for mutation. If the callee writes into a mutable argument, that change flows back out to the caller.

    With the actual to formal edge in place, the analyzer knows term inside run_lookup holds user input, follows it into the string concatenation, and reports the sink. Interprocedural reach is what lets an analyzer say the path from request.args.get to db.execute is a single connected chain, not two unrelated warnings.

    UnboundCompute’s engine builds these edges into a deterministic graph, so the same code produces the same actual, formal, return, and throw edges every time.

    Intraprocedural analysis finds the bug that fits in one screen. Interprocedural analysis finds the bug that hides in the space between functions, which is where most of them live.

    The hard parts, told honestly

    Connecting callers to callees sounds mechanical. It is not, because you cannot always tell which function a call goes to.

    Call resolution

    To draw the edge you need to know the target. For a plain named call to a function defined in the code, that is straightforward. It gets hard fast with imports, aliases, and functions passed around as values.

    Dynamic dispatch

    When you call obj.handle(data), the actual method depends on the runtime type of obj. If three classes define handle, a static analyzer may not know which one runs. It can consider all of them, which adds noise, or guess, which loses paths.

    Function pointers and callbacks

    In C a call through a function pointer has no name attached to it. The same problem shows up in higher level code as callbacks and handler tables, where the function to run is chosen at run time from a variable.

    Reflection and dynamic evaluation

    Calls built from strings, through getattr, eval, or a name looked up in a dictionary, have no static target at all. There is nothing in the text that names the function.

    Why unresolved calls must become boundaries, not blanks

    The tempting mistake is to ignore a call you cannot resolve. That is the worst option, because it silently drops taint. A value flows into a call the analyzer did not understand, the analyzer sees no outgoing edge, and it concludes the value went nowhere. You get a clean report over a real bug.

    The defensible choice is to treat an unresolved call as an explicit boundary. The analyzer records that tainted data reached a call it could not follow and marks that as the edge of what it knows. UnboundCompute’s engine does this: unresolved dynamic calls are emitted as boundaries in the graph, not quietly skipped and not filled in with invented edges. A boundary is an honest statement. It says the trail continues past here and confidence ends, which beats a false all clear or a fabricated path.

    Making cross function tracking tractable at scale needs a real parse of the code, not string matching, so calls resolve through renames and imports. UnboundCompute keeps its code property graph open, and you can read how the edges are modeled in lachesis on GitHub.

    A worked example

    Take a small app, Acme Notes. A route reads a note title, a formatting helper in another module trims it, and a rendering function writes it into a page.

    # routes.py
    title = request.form["title"]          # source
    clean = format_title(title)            # actual to formal edge
    render_note(clean)                     # tainted value passed on
    
    # format.py
    def format_title(t):
        return t.strip()                   # return edge carries taint back
    
    # render.py
    def render_note(text):
        html = "<h1>" + text + "</h1>"   # sink: raw input in HTML
        return respond(html)

    Intraprocedural analysis sees three tidy functions and nothing wrong. Interprocedural analysis follows title into format_title through the actual to formal edge, back out through the return edge as clean, then into render_note where it lands in an HTML string with no escaping. That is a cross site scripting path, confirmed as one connected chain across three files. The difference is only whether the analyzer followed the value across the calls or stopped at each door.

    Reach is what turns a sink into a finding

    A sink on its own is not a bug. db.execute and string concatenation into HTML appear all over healthy code. What makes one dangerous is a live path from an untrusted source to it. An analyzer without interprocedural reach cannot tell those apart, so it flags every sink and leaves you to check each by hand. An analyzer that follows the value can say which sinks are actually reachable from input and which are not. That is the line between a report you trust and a pile of maybes. For more on why following the value beats matching patterns, read source to sink dataflow analysis and the wider scanners vs research category.

    Tracking taint across functions honestly, and marking the boundaries where knowledge ends, is the kind of careful reasoning UnboundCompute is built on. You can read how we approach it on our about page.

    Frequently asked questions

    What is the difference between intraprocedural and interprocedural taint analysis?

    Intraprocedural taint analysis stays inside one function and loses the trail as soon as a value is passed to another function. Interprocedural taint analysis follows the value out of the function, tracking it through arguments, return values, thrown exceptions, and across file and module boundaries, which is where most real bugs actually live.

    Why does a scanner miss bugs that span multiple functions?

    If a scanner looks at one function at a time, it sees a handler that reads input and a helper that runs a query as two separate, clean functions. The input arrives in one and the sink runs in another, and without an edge connecting the call, nothing looks tainted. The bug only appears once the analyzer follows the value across the call from caller to callee.

    How should an analyzer handle a call it cannot resolve?

    It should treat the unresolved call as an explicit boundary, not ignore it. Ignoring a call you cannot follow silently drops taint and produces a false clean report. Recording it as a boundary states honestly that tainted data reached a point the analyzer could not follow, which is far more useful than inventing an edge or reporting nothing.

    Why is interprocedural reach needed to confirm a real vulnerability?

    A sink like a SQL query or an HTML write is not a bug on its own, since it appears throughout healthy code. What makes it dangerous is a live path from untrusted input. Interprocedural reach lets the analyzer show that path as one connected chain, so it can confirm which sinks are reachable from input instead of flagging every sink for you to check by hand.


    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.

  • Source to Sink Analysis Explained

    Source to Sink Analysis Explained

    When a scanner flags a line of code, the first question that matters is simple: can an attacker actually reach it? Source to sink analysis is how you answer that. It traces a value from the place attacker input enters your app, the source, to the place that value gets used in a dangerous way, the sink, and it only rings the alarm when a real path connects the two.

    What source to sink analysis actually means

    Two words carry the whole idea. A source is any value an attacker controls. A sink is any operation that becomes dangerous when fed the wrong value.

    Common sources in a web app or API:

    • Request query parameters, like ?id=42.
    • Request body fields from JSON or form posts.
    • HTTP headers, including Cookie, User-Agent, and X-Forwarded-For.
    • Path segments in the URL.
    • Uploaded file names and file contents.

    Common sinks, where a tainted value turns into a bug:

    • A raw SQL query string passed to the database.
    • A shell command run with os.system or exec.
    • HTML written straight into a response, which is how reflected injection and cross site scripting happen.
    • A file path opened on disk.
    • The destination of an outbound HTTP request, the classic setup for SSRF.

    A source on its own is harmless. A sink on its own is often fine. The bug lives in the line that connects them, and that connection is exactly what dataflow analysis follows.

    Following a value from source to sink

    Data rarely goes straight from the request into the sink. It gets assigned to variables, passed into functions, concatenated, and returned. A tool doing source to sink analysis walks each of those steps and asks, at every hop, does the tainted value still flow forward?

    Here is a short flow with the taint marked at each step:

    def get_user(request):
        uid = request.args.get("id")        # source: attacker controls uid
        return lookup(uid)                   # taint passed into a function
    
    def lookup(value):
        query = "SELECT * FROM users WHERE id = " + value   # taint reaches the string
        return db.execute(query)             # sink: raw SQL runs

    The value starts life in request.args.get("id"). It travels as the argument uid, crosses a function boundary into lookup as value, lands inside a concatenated SQL string, and finally hits db.execute. That unbroken chain is a real path from source to sink. An attacker who sends ?id=1 OR 1=1 changes the meaning of the query, so this is not just scary looking code, it is exploitable.

    Why a validator breaks the chain

    Not every value that touches a sink is dangerous. If something on the path forces the value into a safe shape, the flow is broken and there is no bug to report. This is the difference between a finding and a false alarm.

    Take almost the same code, with one guard added:

    def get_user(request):
        uid = request.args.get("id")        # source
        if not uid.isdigit():               # validator: rejects anything but digits
            abort(400)
        return lookup(int(uid))             # value is now a safe integer

    The isdigit check rejects 1 OR 1=1 before it ever reaches the query, and int(uid) makes it impossible for text to survive. A tool that understands this sees the tainted string die at the validator. The path from source to sink is cut, so no alert fires. The same logic applies to a proper sanitizer, a parameterized query, an allowlist, or an escaping function that the analysis recognizes.

    This is also where these tools earn their keep or fail. If the analysis does not recognize your cleaning function, it will either miss a real bug or cry wolf on a safe one. Recognizing which functions genuinely break the flow is most of the hard work.

    Pattern grep versus real dataflow

    The plainest way to see the value of source to sink analysis is to compare it with a text search.

    A grep style scanner looks for the shape of a sink. Search for db.execute( and it flags every call, whether or not attacker input reaches it. So it fires on this line:

    db.execute("SELECT count(*) FROM users")   # constant string, no input, still flagged

    Nothing an attacker sends can change that query. There is no source, so there is no bug, but the pattern matcher does not know the difference. It flags the sink because the sink exists.

    Pattern matching asks whether a dangerous function is present. Dataflow asks whether attacker input can actually reach it. Only the second question tells you if you have a real bug.

    Dataflow inverts the logic. It starts from the sources, follows the taint, and reports the sink only when a live path connects the two. The constant query above gets no alert because no source reaches it. The concatenated query from earlier does, because one does. That is how you turn a wall of maybes into a short list of paths worth fixing.

    Doing this across a whole codebase

    Tracing one function by hand is easy. Doing it across thousands of files, through imports, class methods, and callbacks, is where it gets hard. The taint might enter in a route handler, pass through three helper modules, and reach a sink defined in a fourth. You cannot hold that in your head, and grep cannot see across those hops at all.

    This is what a code property graph is for. It models the code as a graph of declarations, calls, and data edges, so a query can walk from a source, across every assignment and function call, to a sink, and report the exact path it found. Because the graph is built from a real parse of the language rather than string matching, a rename or an import alias does not hide a caller. UnboundCompute builds an open code property graph for this kind of source reasoning, lachesis on GitHub, so the same walk that is tedious by hand becomes a single query over the whole tree.

    A graph also makes the negative answer trustworthy. When it reports no path from a given source to a given sink, that is a considered result, not a search that happened to miss. For more on how this differs from tools that only match known patterns, see scanners vs research.

    From reachable to exploitable

    Source to sink analysis draws the line between two very different statements. “This code contains a SQL call” is almost never useful on its own. “This request parameter reaches that SQL call with no validation in between” is a bug you can prove and fix. The path is the proof.

    Finding that path is one half of the work. Confirming that it is truly exploitable, and not blocked by some condition the graph could not see, is the other half. That pairing, reason about the path in the source, then verify it against the running app, is exactly the kind of work UnboundCompute is built to do. You can read more on our about page.

    Frequently asked questions

    What is a source and a sink in dataflow analysis?

    A source is any value an attacker controls, such as a request parameter, a header, a request body field, or an uploaded file name. A sink is an operation that becomes dangerous with the wrong input, like a raw SQL query, a shell command, an HTML response, or the destination of an outbound HTTP request. Source to sink analysis reports a bug only when a value flows from one to the other.

    How is source to sink analysis different from grep?

    A grep style scanner flags a sink wherever it appears, even a constant query like db.execute("SELECT count(*) FROM users") that no attacker can influence. Source to sink analysis starts from the attacker controlled sources and only reports the sink when a real path of assignments and function calls connects the two. That is the difference between a wall of maybes and a short list of paths worth fixing.

    Why does a validator stop a finding from being reported?

    If a check on the path forces the value into a safe shape, the tainted value never reaches the sink in a dangerous form. An isdigit guard followed by int(uid) makes SQL injection impossible, so the flow is broken and no alert should fire. Parameterized queries, allowlists, and escaping functions break the chain the same way, as long as the analysis recognizes them.

    Why use a code property graph for this?

    Tracing one function by hand is easy, but real bugs cross many files, imports, and helper calls. A code property graph models declarations, calls, and data edges from a real parse, so a single query can walk from a source to a sink across the whole tree without a rename or alias hiding a caller. You can see one open implementation in lachesis on GitHub.


    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.

  • How to Test Access Control: A Step by Step Method for Web Apps and APIs

    How to Test Access Control: A Step by Step Method for Web Apps and APIs

    Access control is the control that fails most often and the one automated tooling is worst at checking. The reason is simple: a broken access control request is not malformed, so there is no bad pattern to detect. Deciding it is a bug requires knowing who was supposed to be allowed. This guide covers how to test access control in a web application or API, in an order you can actually work through, and what a correct result looks like at each step.

    Before you start: build the matrix

    Access control testing is comparison testing, so the first job is having things to compare. You need accounts and you need an inventory.

    • At least two accounts at the same level, so you can test one member against another. A single account cannot reveal a horizontal bug.
    • One account per privilege level the product has: member, administrator, billing owner, support, and anything internal.
    • Two tenants if the product is multi tenant. Two workspaces owned by different people is the only way to test the boundary that matters most to your customers.
    • A list of objects and who owns them. Note the ids created by each account. You will use this constantly.
    • A capture of normal traffic per level. Drive the application as each account with a proxy or the browser network tab recording. That capture is your test suite.

    Write down, in one table, which roles are meant to be able to do what. Most teams have never written this down, and the act of writing it usually surfaces two or three rules nobody had agreed on.

    How to test access control, step by step

    1. Swap object ids between accounts

    Signed in as user A, request the objects owned by user B. Change the id in the path, the query string, the JSON body, and any header that carries one.

    GET /api/notes/4121
    Authorization: Bearer tokenForUserA
    
    expected: 403 Forbidden or 404 Not Found
    finding:  200 OK with user B's data

    Repeat per verb, because read and write are authorized separately. A GET that is correctly denied says nothing about the PATCH or DELETE on the same object.

    2. Replay privileged requests with an unprivileged token

    Take the capture from the admin account and send those exact requests with a member token. This is the fastest test in the whole list and it finds the most serious bugs, because a route that only checks that you are signed in will answer anyone who is.

    3. Try to write fields you should not control

    Add attributes to bodies that do not document them, then read the record back to see whether the value stuck.

    PATCH /api/users/me
    { "display_name": "Sam", "role": "admin", "workspace_id": 9 }

    The usual candidates are role, is_admin, plan, scopes, owner_id, tenant_id, and verified. A response that echoes your value back is not proof on its own. Fetch the object again as a different account to confirm the change persisted.

    4. Follow every object into its quiet paths

    The direct fetch is the route people remember to protect. The same record is usually reachable through several others.

    • List and search endpoints, which often filter in the interface rather than the query
    • Export and report jobs, which run in the background with service credentials
    • File downloads and signed links, where the link may outlive the permission
    • Notification emails and webhooks, which quote object contents to whoever is subscribed
    • Older API versions still routed and no longer maintained

    5. Test permission over time, not just at one moment

    Access control has a lifecycle, and testing at a single point misses the whole class of stale authority.

    • Demote an account, then reuse the token it was issued before the change
    • Remove a member from a workspace, then replay their earlier requests
    • Cancel an invitation, then accept it
    • Delete an object, then request it directly by id, and check restore and undelete paths

    6. Check the tenant boundary explicitly

    With an account in workspace A, try to read, write, and invite into workspace B. Then do it through the quiet paths from step 4. Cross tenant leakage is the finding that ends enterprise deals, and it is frequently absent from an application’s test suite entirely.

    A correct response is a denial you can prove, not the absence of a link in the interface. If the button is hidden but the endpoint answers, the control does not exist.

    What a correct result looks like

    • 403 or 404 on every unauthorized request. Prefer 404 for objects the caller should not know about, since a 403 confirms the record exists.
    • Denials that come from the server, reproducible outside the browser with a raw request.
    • Consistent answers across paths. If the direct fetch denies and the export includes the record, the control is not enforced, it is decorated.
    • A test per object route asserting that user A cannot reach user B’s object. Without these, the next refactor quietly reopens what you just fixed.

    Where automation helps and where it does not

    Scanners are good at the parts with a signature: missing authentication on a route, a directory that lists, a known vulnerable component. They struggle here because every request in this guide is legal. The tool has no way to know that note 4121 belongs to someone else, or that only the billing owner should change a plan. That knowledge is specific to your application and it does not exist anywhere in the code in a checkable form. More on where scanners stop and research starts is here.

    What can be automated is the part that is mechanical once the intent is understood: holding several identities at once, enumerating every object and route, and replaying each request as each account. That is a lot of combinations and exactly the sort of work people skip when a release is due. It is also the direction we are building in. As an early signal, 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. We are still early and it needs supervision, but the shape of the work suits a system that can reason about what an application is for rather than what its inputs look like.

    If you do only one thing from this guide, do step 2. Record what an administrator does, replay it as an ordinary member, and see what answers. It takes an afternoon and it finds the bugs that matter. Read more about how UnboundCompute works.

    Frequently asked questions

    How do you test access control in a web application?

    Create at least two accounts at the same level plus one per privilege level, record the objects each one owns, and capture the normal traffic of each. Then run six checks: swap object ids between accounts, replay admin requests with a member token, try to write fields such as role that you should not control, follow each object into list, export and download paths, retest after permissions change, and probe the tenant boundary. A correct application answers 403 Forbidden or 404 Not Found every time.

    What is the fastest access control test to run?

    Record everything an administrator account does, then send those exact requests using an ordinary member token. It takes an afternoon and it finds the highest severity issues, because any route that checks only that you are signed in will answer whoever asks. Hiding a button removes the path a normal user takes to an endpoint, not the endpoint itself.

    Why can scanners not find access control bugs?

    Because the requests are legal. Every field has the right type, the session is valid, and the response is a clean 200 OK. A scanner works by comparing traffic against known bad patterns, and there is no pattern here to match. Knowing that a particular record belongs to a different user, or that only a billing owner may change a plan, is knowledge about your specific application that does not exist in the code in any checkable form.

    How many test accounts do I need for access control testing?

    At least four in most products. Two accounts at the same level, because a single account cannot reveal a horizontal bug where one member reads another member’s data. One account per elevated level, such as administrator or billing owner. And if the product is multi tenant, a second workspace owned by an unrelated person, since cross tenant access is the failure customers care about most and the one least often covered by an existing test suite.


    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.

  • SAST vs DAST vs IAST, what is the difference?

    SAST vs DAST vs IAST, what is the difference?

    If you have shopped for application security tools, you have run into the alphabet soup of SAST, DAST, and IAST. The sast vs dast question is the one most teams start with, but IAST sits in the middle and changes the answer. This post gives plain definitions for all three, shows what each catches and misses, and is honest about where they fall short.

    The short version of sast vs dast vs iast

    The three tools differ by where they stand and what they can see.

    • SAST (Static Application Security Testing) reads your source code without running it. It looks for dangerous patterns in the text of the program.
    • DAST (Dynamic Application Security Testing) tests the running app from the outside, like an attacker with no source code. It sends requests and reads responses.
    • IAST (Interactive Application Security Testing) watches from inside the running app. An agent sits in the process and sees both the incoming request and the line of code that handles it.

    SAST: reading the source code

    SAST parses your code and models how data flows through it. It traces a value from where it enters, such as a request parameter, to where it gets used, such as a database query or an HTML response. If tainted input reaches a dangerous function without being cleaned, SAST flags it.

    Here is the kind of flow SAST is good at spotting:

    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 SAST follows that path from input to sink.

    What SAST catches

    • Injection patterns: SQL, command, and template injection where input flows into a sink.
    • Hardcoded secrets, weak crypto calls, and unsafe deserialization. Spotting a key or password committed straight into source is a classic static analysis check, and our free secret scanner runs that same kind of pattern check over code you paste in.
    • Bugs on code paths that are hard to reach with traffic, since SAST reads every branch whether or not it runs.

    What SAST misses

    • Anything that depends on configuration or the live environment. A query that looks unsafe may sit behind a parameterized layer SAST cannot model.
    • Logic that lives in a framework, a stored procedure, or a third party library the scanner does not parse.

    DAST: testing the running app from outside

    DAST treats the app as a black box. It crawls the pages, finds inputs, and throws payloads at them to see how the app reacts. If a request returns a database error or a reflected script, DAST records a finding.

    A simple DAST probe for reflected cross site scripting looks like this:

    GET /search?q=<script>alert(1)</script> HTTP/1.1
    Host: acmenotes.example

    If that <script> tag comes back in the HTML response unescaped, the app is reflecting raw input and DAST flags it.

    What DAST catches

    • Real behavior of the deployed app, including server config, headers, and TLS settings.
    • Reflected and stored injection, broken authentication flows, and missing security headers.
    • Issues that only show up once everything is wired together.

    What DAST misses

    • Code paths it never reaches. If the crawler does not find a form or an API route, that route is never tested.
    • The exact line of code at fault. DAST tells you the app misbehaved, not where in the source to fix it.
    • Bugs that need a valid login or a specific account state the scanner cannot reproduce.

    IAST: watching from inside while the app runs

    IAST puts an agent inside the running process, often through the language runtime. As traffic flows through the app, the agent sees the request, follows the data through the code that executes, and watches it reach a sink. It is dynamic like DAST, but with the inside view DAST lacks. So it can say something precise: this request reached this query on this line with this tainted value. That pairing is its main advantage.

    What IAST catches

    • Injection and input flaws confirmed against code that actually ran, so fewer guesses.
    • The specific file and line, which makes the fix faster than with DAST alone.
    • Flaws deep inside libraries, since the agent watches data move through them at run time.

    What IAST misses

    • Code that is never exercised. IAST only sees paths that real traffic or tests drive, so coverage depends on how thoroughly the app is used during testing.
    • Languages and runtimes the agent does not support, since instrumentation is tied to the platform.
    • Bugs outside the instrumented process, such as flaws in a separate service.

    Side by side: sast vs dast vs iast

    • SAST. Sees source code, does not run the app. Strong on coverage of every branch. Weak on run time and config reality.
    • DAST. Sees outside behavior, runs the app, needs no source. Strong on real deployed behavior. Weak on pointing to the exact code.
    • IAST. Sees inside the running app, needs runtime access. Strong on precise, confirmed findings. Weak on coverage of paths that never run.

    Where false positives come from

    Each tool gets noisy for its own reason.

    • SAST flags a path that looks dangerous but is safe, because it cannot see that a value was validated in a way it does not model, or that the path is dead code.
    • DAST reads a response and guesses. A database error in the page can be a leftover string, not proof of injection, so it raises a finding that is not real.
    • IAST is usually the quietest, because it confirms a finding against code that ran. Even so, it can mistake a safe sanitizer for a missing one if it does not recognize the cleaning function you use.

    The cost is real. Every wrong alert is time a developer spends ruling it out, and a backlog of noise trains teams to ignore the tool.

    The honest limit: none of them understand business logic

    Here is the part the vendor pages skip. All three look for known shapes of bugs. None understands what your app is supposed to do.

    Pattern matchers find the bug they were told to look for. They do not ask whether a user who can read invoice 41 should be able to read invoice 42.

    Consider GET /api/invoices/42 where the logged in user only owns invoice 41. Nothing in that request is malformed. No script tag, no SQL, no broken header. SAST sees clean code, DAST sees a normal 200 response, and IAST sees a safe query running. They all agree the request is fine, and they are all wrong, because the app forgot to check who owns invoice 42. This is broken access control, one of the most common serious bugs in real apps, and the scanners miss it because there is no pattern.

    For more on this gap between pattern matching tools and real reasoning about an app, read scanners vs research.

    So which one do you need?

    For most teams it is not one tool but a stack. SAST runs early on every commit and catches obvious sink bugs before they ship. DAST runs against a deployed build and shows how the real app behaves. IAST rides along with your existing tests and gives precise findings on the paths your traffic touches. They overlap, and that overlap is fine, because each one fails in a different place.

    What none of them replaces is a tester who reads the app’s logic and asks whether its assumptions hold. That assumption testing is exactly the kind of work an autonomous researcher is built to do, looking past fixed payload lists to the rules an app quietly trusts. Read how we think about it on our about page.

    Frequently asked questions

    What is the difference between SAST, DAST, and IAST?

    SAST reads your source code without running it and looks for dangerous patterns in the text. DAST tests the running app from the outside like an attacker with no source code. IAST puts an agent inside the running process so it sees both the incoming request and the line of code that handles it. The OWASP guidance on source code analysis tools covers the static side in more depth.

    Which is better for finding bugs, SAST or DAST?

    Neither is strictly better because they fail in different places. SAST covers every code branch whether or not it runs but cannot see live config or runtime reality, while DAST shows real deployed behavior but cannot point to the exact line of code or reach routes its crawler never finds. Most teams run both rather than picking one.

    Why does IAST usually have fewer false positives?

    IAST confirms a finding against code that actually ran, watching a tainted value reach a specific sink on a specific line, so it guesses less than DAST or SAST. It can still misfire if it does not recognize a safe sanitizer you use, and it only sees paths that real traffic or tests exercise.

    Can SAST, DAST, or IAST find broken access control?

    Usually no. A request like GET /api/invoices/42 from a user who only owns invoice 41 is well formed, returns a normal 200, and runs a safe query, so all three tools see nothing wrong. They look for known bug shapes and do not understand which user should be allowed to read which object.


    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, a say in what it looks for, and founding pricing. 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.

  • What is automated penetration testing?

    What is automated penetration testing?

    If you run a web app or an API, you have probably heard the phrase tossed around in security pitches. So what is automated penetration testing, and how is it different from the vulnerability scanner you may already run? In short, it is software that pokes at your application the way an attacker would, then tries to confirm what it finds, instead of a person doing every step by hand.

    This guide is for people who are new to the topic. We will define the term, compare it to a manual pentest and to a plain scanner, and be clear about what each tool is good at and where it falls short.

    What is automated penetration testing, in plain terms

    A penetration test, or pentest, is an authorized attempt to break into a system so you can fix the holes before a real attacker finds them. A human tester explores the app, forms a theory about what might break, and tries to exploit it. Automated penetration testing hands much of that loop to software. The tool maps the application, picks targets, sends crafted requests, and reports what got through.

    The word that matters here is exploit. A good automated pentest does not just say “this parameter looks risky.” It tries to actually use the weakness and shows you the result.

    The difference that counts is proof. A flag says maybe. A working exploit says yes, and here is the evidence.

    How it differs from a manual pentest

    A manual pentest is run by a person, often over one or two weeks, against a defined scope. Humans are good at understanding what an app is for. They read the screen, guess at business rules, and chase odd behavior that no rulebook predicted.

    Automation trades some of that judgment for speed and repeatability. Here is the honest trade:

    • Speed. Software can test thousands of requests in the time a person tests a handful.
    • Repeatability. You can run the same checks every night and on every deploy, not once a year.
    • Coverage of known classes. It is steady at the well understood bugs, like reflected injection or a missing access check on a predictable URL.
    • Weaker on context. It struggles with rules that only a human reading the app would know, such as “a trial account must never export the full customer list.”

    The two are not rivals. Many teams run automation often and bring in human testers for deep, scoped work on the parts that matter most.

    How it differs from a plain vulnerability scanner

    This is the comparison most people get wrong, so it is worth slowing down. A vulnerability scanner checks for known issues and reports anything that matches a signature. It might flag an out of date library, an open port, or a parameter that reflects input back to the page. That is useful, but a scanner usually stops at “this looks suspicious.”

    An automated pentest goes one step further and tries to prove the issue is real. Take a classic example. A scanner sees this request and notices the id value is reflected in the response:

    GET /api/invoices?id=1042
    Authorization: Bearer trial-user-token

    The scanner says: possible insecure direct object reference, please review. An automated pentest treats that as a theory to test. It changes the value and watches what comes back:

    GET /api/invoices?id=1043
    Authorization: Bearer trial-user-token
    
    HTTP/1.1 200 OK
    { "id": 1043, "customer": "Acme Notes", "total": 8800, "card_last4": "4242" }

    Now there is evidence. The trial user just read another customer’s invoice. That is no longer a maybe. It is a confirmed access control bug with a request you can replay. If the deeper reading on this distinction is what you are after, the scanners vs research category goes through it in more detail.

    Flagging versus verifying

    Hold this difference in your head, because it shapes everything else:

    • A scanner flags. It hands you a list of candidates ranked by severity, and a human has to check each one.
    • An automated pentest verifies. It tries the attack and keeps only the findings it could actually reproduce.

    The most useful tools sit on the verifying side. A short list of proven bugs is worth more than a long list of maybes, because every false alarm costs someone an hour of triage.

    What automated penetration testing is good at

    Used well, it earns its place. It is strong at:

    • Breadth. Checking every endpoint, every parameter, on a schedule a human could not keep.
    • Regression. A confirmed bug can become a repeatable check that watches for the same hole reappearing after a future deploy.
    • Fast feedback. Running on each release means a new flaw gets caught in days, not at the next annual review.

    Where it falls short

    Honesty matters more than the sales pitch, so here are the real limits.

    Logic bugs

    The bugs that hurt most often live in business logic, and those are the hardest to automate. Consider a checkout flow that applies a discount code. A tool that only sends known payloads will not think to apply the same code twice, or to set the quantity to a negative number so the total drops below zero. Those attacks come from understanding what the app is trying to do, then asking what happens if you bend a rule. A fixed payload list does not reason that way.

    Context and intent

    Software does not know your business rules unless someone teaches it. It cannot tell that a field labeled role should never be editable by the customer, or that an internal admin route was left exposed by accident. Without that context, it tests the requests it can see and misses the ones that only make sense once you understand the product.

    False positives and noise

    Tools that flag without verifying drown teams in noise. After enough false alarms, people stop reading the report, and a real finding gets lost in the pile. This is exactly why the verifying approach matters: proof cuts the noise.

    What good looks like

    If you are choosing a tool, look past the feature list and ask one question: does it prove its findings? The better systems do not just match patterns. They learn how the app is meant to work, form an idea about where that logic could break, design a test, and then confirm the result with concrete evidence before they bother you. Understand, assume, experiment, verify.

    The highest impact bugs come from understanding the app, not from matching a known string. That is the bar worth holding any tool to.

    Closing

    So, to answer the question plainly: automated penetration testing is software that attacks your app like an attacker would and, in its best form, proves what it finds rather than just listing suspects. It is fast and tireless on known issues, and weaker on logic and context, which is where a human or a smarter system earns its keep. This is the gap UnboundCompute is built to close, an autonomous researcher that tests the assumptions your app makes and proves a finding with hard evidence before reporting it. You can read more on the about page.

    Frequently asked questions

    Is automated penetration testing the same as a vulnerability scan?

    No. A vulnerability scanner flags anything that matches a known signature and usually stops at “this looks suspicious,” while automated penetration testing goes further and tries to actually exploit the weakness, then keeps only the findings it could reproduce. The difference is proof: a scanner hands you candidates to triage, an automated pentest hands you confirmed bugs with a request you can replay.

    Can automated penetration testing replace a human pentester?

    Not for everything. Automation wins on speed, breadth, and repeatability, so it is well suited to checking every endpoint on a schedule and catching well understood bugs like reflected injection or a missing access check on a predictable URL. It is weaker on business logic and context, which is why many teams run automation often and still bring in human testers for deep, scoped work.

    Why does automated penetration testing miss business logic bugs?

    Because logic bugs use input that is completely legal, so there is no signature to match. A tool that only sends known payloads will not think to apply a coupon twice or set a quantity to a negative number, since those attacks come from understanding what the app is trying to do rather than from a fixed payload list. You can read more on the OWASP Web Security Testing Guide.

    What should I look for when choosing an automated pentest tool?

    Ask one question: does it prove its findings? The stronger tools do not just match patterns, they learn how the app is meant to work, form an idea about where the logic could break, design a test, and confirm the result with concrete evidence before reporting it. A short list of proven bugs is worth more than a long list of maybes, because every false alarm costs someone time to triage.


    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, a say in what it looks for, and founding pricing. 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.