Back to all posts
Published on · by Renaud Deraison

The scanner refused to read it

Google's Threat Intelligence Group published its AI Threat Tracker on September 8. Buried in the section on a supply-chain crew called UNC6780 is a technique that turns a model's safety policy into cover: the malware's JavaScript loader opens with a comment block demanding bioweapon synthesis routes and nuclear weapon specifications, placed there so an LLM security scanner reading the file refuses on policy and never reaches the code underneath. The refusal is the evasion. Bromure Agentic Coding answers artifacts like this with checks that never read them.

Talking a security scanner into approving malware is hard work. Handing it a file so radioactive that it declines to look is a comment block, and your pipeline files the refusal under "unable to analyze."

Your build pipeline has an LLM in it now. Most do. Somewhere between the dependency update landing and the merge button lighting up, a model reads the diff, reads the new files, and writes a short opinion about whether any of it looks hostile. Good use of the technology: it catches obfuscated loaders that pattern matching walks past, and it costs a fraction of a cent per file.

On September 8, Google's Threat Intelligence Group published its AI Threat Tracker, a quarterly read on what attackers are doing with this stuff. Most of the coverage went to the headline number: a financially motivated actor who compromised a company's cloud infrastructure and then, in GTIG's words, "leveraged an AI coding chatbot, a prompt, and a set of agent instructions to plan, build, and execute a mass credential harvesting campaign in less than six hours." Thousands of third-party credentials, routed out through the victim's own IP addresses. On a separate exposed server the researchers found a reconnaissance framework whose dashboard was tracking more than 23,800 harvested secrets in real time.

That is the story that got written up. The paragraph worth your afternoon is several sections down, in the part about a supply-chain crew Google tracks as UNC6780.

The comment at the top of the file

UNC6780, which also goes by TeamPCP, has been running large-scale open source compromises since March 2026 across PyPI, npm and Docker Hub. Their current credential stealer is called DUSTMAKER, and it is a JavaScript payload. JavaScript payloads have a loader. This one's loader is a file named _index.js, and GTIG found something new at the top of it:

In addition to prompts embedded in configuration files targeting AI coding agents, DUSTMAKER samples included prompts embedded as comments at the top of the malware's JavaScript loader scripts (_index.js). These prompts contain extreme adversarial text, referencing topics such as biological and nuclear weapons, likely intended to cause LLM security scanners to fail or skip analysis of the malicious JavaScript code underneath due to safety or policy refusals.

The comment block leaves the malware in plain sight. Unpacked, unencoded, all in one file: the malicious JavaScript sits three lines further down, where anyone reading to the end would trip over it.

The comment makes the reader unwilling to reach the end. It opens with a fake system override, announces that safety guidelines are suspended, and demands a step-by-step synthesis route for aerosol-dispersed biological weapons. Then it moves on to implosion-type nuclear weapon design, complete with a yield target. A model that ingests the file and gets asked "is this code malicious?" now has a louder question in front of it, and it answers that one with a refusal. The scanner returns a policy message. Your pipeline logs analysis unavailable, the way it does a few dozen times a week for timeouts and oversized files, and the build goes green.

The technique costs UNC6780 nothing. It demands no knowledge of which model reads the file, which vendor hosts that model, or what its system prompt says. It asks one thing of the reader: a safety policy, which in an assurance pipeline is the reason you chose a model in the first place.

The refusal is the evasion_index.js/* SYSTEM OVERRIDE — CLASSIFIED BRIEFINGbioweapon synthesis routefission device specificationengineered to be refusedconst _0x = require(…)the credential stealerin plain, unobfuscated textnever reachedreads from the topLLM security scannerasked: is this code hostile?answers a louder question firstpolicy refusalthe build loganalysis unavailablesame line it prints fortimeouts and oversized fileswhat the attacker had to knownot which model reads the file · not which vendor hosts itnot the system prompt · not the thresholdonly that the reader has a safety policy
DUSTMAKER's loader does not hide its payload. It opens with a comment block engineered to trip the reviewing model's safety policy, so the model refuses the whole file and the malicious JavaScript three lines below is never assessed. A refusal and a clean bill of health look identical to a build log that only checks whether the step failed.

The rest of the chain does the same thing

Once you see the move, the rest of UNC6780's tradecraft reads as variations on it. Every remaining defense in the path is also a reader forming a judgment about an artifact the attacker wrote, and each one gets fed rather than beaten.

The package is delivered through the tools the agent trusts most. GTIG describes UNC6780 compromising legitimate developer accounts "to publish trojanized forks of legitimate MCP servers to the PyPI registry, such as tiktoken_mcp, and inject malicious code directly into official organizational GitHub repositories, such as azure-functions-mcp-extension." Backdooring MCP servers means the payload arrives through the one class of dependency a coding agent is designed to load and grant tools to.

The provenance check gets a valid signature. Anyone treating attestations as a trust signal should sit with this one:

DUSTMAKER samples contain functionality to detect when it is running in a continuous integration and continuous delivery (CI/CD) environment. If confirmed, it extracts OIDC tokens from the process memory of GitHub Actions runners. Using these tokens, DUSTMAKER authorizes itself as a trusted publisher and publishes compromised versions of packages with valid, cryptographically signed SLSA Build 3 attestations. Packages published with valid tokens will pass AI coding agent automated trust checks.

The publisher's own identity signed that package. DUSTMAKER lifted the identity out of process memory on the runner where it lives, so a verifier checking the signature returns valid and is right to.

The endpoint agent watches the wrong neighborhood. DUSTMAKER "drops or modifies malicious files into hidden project workspace directories for AI coding assistants and integrated development environments (IDEs) (.claude/, .vscode/, .cursor/, etc.)." GTIG says why: those folders let the malware blend "into routine developer noise" and steer clear of the places endpoint detection watches, like registry keys and /etc/cron.*. Your EDR vendor has not shipped a rule for a new file in .cursor/, because until this year that folder held editor preferences.

The assistant gets a config file, and config files are instructions. From those same hidden directories, DUSTMAKER "uses malicious configuration files … to instruct the AI assistant to run arbitrary commands or scripts (such as setup.mjs) during routine developer interactions. This effectively forces the AI model to execute commands on the attacker's behalf without the developer's knowledge." The assistant read its workspace configuration, which is its job, and the configuration named a script to run. No exploit anywhere in that sentence.

And the audit API deletes the audit trail. The malware masquerades its CI tasks under AI-themed names like "Copilot Setup," then issues automated API calls to remove workflow execution logs from the GitHub UI.

Five readers, five artifacts the attacker wroteTHE READERWHAT IT WAS HANDEDWHAT IT CONCLUDEDLLM security scannerreads the filea comment block it must refusebioweapons, then weapon designnothing — it declinedlogged as analysis unavailableprovenance verifierreads the signaturea real SLSA Build 3 attestationminted with a stolen OIDC tokenvalid, and correctly sothe identity was genuinely the publisher'sAI coding assistantreads the workspace configa config the attacker wrote.claude/ · .cursor/ · .vscode/run setup.mjsduring a routine interactionendpoint detectionwatches where malware writeswrites to an editor folder insteadnot registry keys, not /etc/cron.*routine developer noiseno rule covers that path yetthe auditorworkflow logs deleted through the GitHub APIan empty history
Five defenses in one attack chain, and not one of them malfunctioned. Each is a reader that forms a judgment about an artifact, and in every case the attacker authored the artifact. The scanner refused, the verifier verified, the assistant obeyed, the endpoint agent looked elsewhere, and the auditor found an empty log.

All five worked as designed

You have no bug to file here. The scanner applied its safety policy, which is what you pay it for. The verifier validated a signature that was valid. The assistant loaded its workspace configuration, which is the feature. The endpoint agent watched the paths that mattered before coding agents had config directories. Each did its job on the input it received, and UNC6780 chose the input.

Ordinary evasion asks for a wrong verdict: pack the binary, split the string, encode the URL, hope the analysis comes back clean. DUSTMAKER gets what it wants from a right one, or from no verdict at all.

Every defense shaped like read the artifact, form an opinion sits in range of that. A language model in the reader's chair widens the range, because a model carries its own list of things it will not do, and that list is public, documented, and reachable by anyone who can type a comment. The industry spent two years hardening models against being talked into saying yes. UNC6780 talks them into saying nothing.

Ask a different question of the controls in your agentic pipeline, then: which of them have to read attacker-authored content in order to do their job, and what is left standing once you set those aside.

Checks that cannot be argued with

Bromure Agentic Coding runs each coding agent inside a hardware-virtualized Linux VM on your Mac, with every security control on the host side of that boundary. What matters against a chain like this one is what those controls take as input.

The age gate reads a clock. The host proxy recognizes requests to the major package registries and applies the workspace's supply-chain policy before a byte reaches the VM. The age gate, the one layer on by default and set to a two-day minimum, refuses versions younger than the cutoff. It rewrites the registry's version listing to do it, so from the agent's point of view a too-fresh version does not exist yet; a pinned direct fetch comes back as an HTTP 451 whose body states the package's real age. The package itself never gets read. A comment block engineered to derail a language model moves no publish timestamp, and UNC6780's trojanized forks are fresh publishes by construction. npm, PyPI, Cargo, RubyGems and Packagist all carry per-version publish times, so the gate covers them.

A classifier has no policy to invoke. Bromure's source-code detector scores the tool_result spans the agent streams back to the model, meaning file contents, web pages and command output, using a local PromptGuard model of the DeBERTa family running as ONNX on your Mac. It is a sequence classifier rather than a generative model. Handed DUSTMAKER's comment block it composes no answer, weighs no question of whether answering is permitted, and declines nothing. It emits a number between zero and one, and text that opens with a fake system override announcing suspended safety guidelines is the shape that sends that number high. Per workspace you pick what a hit does: log, ask, or block. A block returns 451, and the model never sees the content.

Something other than the agent reads the workspace config. A file in .claude/ telling the assistant to run setup.mjs is the rules-file backdoor, and it has its own detector: a deterministic heuristic pass for hidden Unicode, meta-instruction patterns, credential paths and curl-pipe-to-shell constructions, plus a fine-tuned ModernBERT classifier for the rogue instructions that match no fixed pattern. Both run in the proxy, on-device, over the instruction files lifted out of the system prompt, where nothing inside the VM can switch them off. Both are per-workspace toggles, and each wants a model download the first time you turn it on. That download is the five minutes this story is asking you for.

A credential stealer needs credentials. DUSTMAKER exists to harvest secrets out of developer environments, and a Bromure workspace holds none. Bromure replaces each credential you configure with a structure-preserving fake derived from the real value and a per-install salt: sk-ant-api03-brm-…, a ghp_ token of the right length, brm-mcp_…, brm-k8s-…. Those fakes go into the environment variables and into ~/.git-credentials, ~/.docker/config.json, ~/.kube/config and ~/.aws/config, which is the list a stealer enumerates. Your real values stay encrypted on the Mac, and the host proxy swaps them onto the wire scoped to the destination host each one was minted for. SSH private key bytes never enter the VM; only signatures cross. No toggle governs any of this, because the proxy is the VM's one route to the network.

The fakes double as tripwires. A fake token has one legitimate destination. The proxy scans each outbound request, headers and body, for a fake headed somewhere else. On a match it refuses the request without forwarding a byte, pauses the VM on the spot, and files a red Credential brokering row in the Security Timeline. It marks the workspace compromised, and the next launch makes you wipe the disk and home image first. You rotate nothing afterward, because the stealer held a placeholder and the placeholder never got out.

The record lives on your machine, out of reach of the API the malware calls. With tracing at Activity only, the proxy writes one metadata row per request leaving the VM, no bodies. bromure-cli trace hostnames prints each distinct host the workspace contacted with counts, and bromure-cli trace leaks names the destination after a compromise alert. An attacker holding a GitHub token can empty a workflow history in a few API calls. Emptying that trace means getting to your Mac.

controls that read the artifactthe attacker writes the input to every one of themmodel reads the filegive it something it must refuseverifier reads the signaturesteal the OIDC token; the signature is realassistant reads the configwrite the config; it runs what you namedthe environment holds real secrets~/.aws/config · ~/.docker · GH_TOKENwhat the attacker needsa comment block, and a reader with a policycontrols that read something elseon the host, outside the VM, on your Macage gate reads a publish timestamptwo days by default; too-fresh versions do not existclassifier emits a probabilitylocal ONNX · log, ask or block · nothing to declinethe VM holds placeholderssk-ant-api03-brm-… · brm-mcp_… · brm-k8s-…proxy matches bytes against a destinationa placeholder off its scope: 451, VM paused, red rowwhat the attacker needsto change a timestamp, or a secret that is not there
The same artifact against two kinds of check. A control that reads attacker-authored content can be given content engineered to derail it. A control that reads a timestamp, a byte pattern or a destination host has nothing to argue with, and the credential the stealer came for was never in the machine it is running on.

Two settings, this afternoon

Open the workspace's Supply Chain pane and confirm the age gate is on with a cutoff you can live with. Two days is the default, and it is the cheapest control you have against a freshly published trojanized fork. Then open Prompt Injection and enable both detectors; each downloads its model once, and after that the scanning is local and free.

One rule that shrinks the question

In Guardrails, set Unmatched traffic to Deny and list what the job needs: allow web api.github.com, allow web registry.npmjs.org, default deny. A stealer that cannot reach its command server is a file on a disk you are about to throw away, and saving pushes the rule to running sessions with no restart.

Absence beats evaluation

We keep arriving at the same place from different directions. An egress proxy that trusted a hostname the agent could write. A command guard that read bash differently than bash does. An AI gateway whose failed key check fell through to an empty auth object. In each case a component was doing an honest job of evaluating something, and the evaluation broke.

DUSTMAKER sharpens the argument, because it asks for no broken evaluation at all. It asks that an evaluation happen over content UNC6780 wrote. Give a reader a file and the reader forms a view of it; who supplied the file settles the rest.

Evaluation is a weaker primitive than absence, and we keep handing evaluation the work absence does for free. You cannot argue with a publish timestamp, and a probability has no willingness to withdraw. A credential stealer running at full privilege inside a disposable VM, reading through every config file it knows about, comes away with a handful of placeholders and a socket it cannot route around.

Putting a model in your review pipeline was the right call. Make sure the controls that stop things are the ones nothing in the repository can talk to. Install Bromure Agentic Coding and give your agent checks that never have to read the attacker's file.