Published
- 34 min read
The Notepad++ Plugin Campaign and the Editor Extension Blind Spot
Stay Safe Online Without Making It Your Second Job
The Digital Fortress (Second Edition)
A warm, plain-English guide for people with real lives and finite patience. Learn the handful of habits that genuinely protect your money, accounts, and family, and get honest permission to ignore the rest.
For People Who Cannot Afford to Get Privacy Wrong
The Anonymity Playbook (Second Edition)
A practitioner’s field manual for journalists protecting sources, whistleblowers, and activists. It explains how the surveillance actually works, what each technique costs you, and exactly where it fails.
Write, Ship, and Maintain Code Without Shipping Vulnerabilities
Secure Software Development
A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.
Use AI Coding Agents Without Losing Control of Your Codebase
The Secure Harness
A calm, practical guide to letting agents do useful work inside boundaries you set, enforce, and audit. Ships with 15 copy-pasteable artifacts: hook scripts, permission configs, release gates, and MCP templates.
Stop Shipping Demos. Start Shipping Systems.
The AI Native Engineer
Sixteen hands-on chapters, one real product. Grow it from a single model call into a retrieved, tool-using, observable, production-grade system, with evaluation treated as a habit from the first feature.
A legitimate installer with an extra passenger
Ukraine’s CERT documented a campaign in July 2026 distributing malware through a malicious Notepad++ plugin bundled alongside otherwise legitimate distributions of the editor. The plugin extracted a password-protected RAR archive, which eventually loaded a command-and-control client.
Reporting tied the activity to Notepad++ version 8.8.3. The developers disputed that framing, pointing out that loading plugins from the plugin directory is documented, intended behaviour rather than a vulnerability. They are correct on the technical point, and the disagreement is more interesting than either position alone.
Nothing was exploited. A user ran an installer, the installer placed a DLL in a directory the editor loads from, and the editor loaded it. Every component behaved as designed. The password-protected archive is worth noting separately: encrypted archives cannot be inspected by scanners without the password, and the password sits in the loader, so the malicious payload is opaque to endpoint tooling until the moment it is extracted.
Why editors are an attractive target
The privilege picture explains the appeal. An editor extension runs inside the editor process, with the permissions of the user running it, and that user is a developer.
That means access to source code for everything the developer works on, including private repositories. It means SSH keys, cloud credential files, and the .env files that hold production database URLs. It means the ability to modify code before it is committed, which is the most valuable capability on the list, because a change introduced at the editor is a change that passes code review as the developer’s own work.
Extensions also run constantly and for long periods, which is good for persistence, and they generate network traffic as a matter of course when fetching updates and language servers, which gives a beacon somewhere to hide.
Compare that to compromising a build server, which security teams monitor closely, and the developer workstation starts looking like the softer path to the same objective.
The same pattern in every other ecosystem
Notepad++ is the example in front of us and the least representative one, because the ecosystems with the largest extension catalogues have produced the largest incidents.
In May 2026 a compromised release of the Nx Console extension reached the Visual Studio Marketplace. Reporting put the malicious version’s availability at somewhere between eleven and eighteen minutes before it was pulled. That was enough. The payload pulled credentials from cloud provider configuration files, CI systems, password managers, and AI coding assistants, and analysis of the incident described roughly 3,800 internal repositories exposed across affected organisations. Eighteen minutes on a marketplace with automatic updates reaches a very large number of machines, because nobody has to click anything.
Earlier the same year, researchers at Koi Security flagged two AI assistant extensions, published under names presenting them as front-ends for well-known chat services, which were quietly shipping developer files to servers abroad. Combined installs were reported at around 1.5 million. Popularity had been doing the vetting.
Wiz published research in October 2025 that explains how compromises like these keep arriving. Scanning published extensions, the team found more than 550 validated secrets across over 500 extensions, including more than a hundred working Visual Studio Marketplace personal access tokens and more than thirty Open VSX access tokens. Those tokens grant permission to publish updates. The install base sitting behind the leaked publishing credentials came to roughly 150,000. The mechanism was mundane. Publishers packaged their own dotfiles into their extension bundles, so a .env sitting in the project directory ended up inside the .vsix, and a .vsix is a zip file that anybody can download and open.
Two structural quirks round the picture out. Marketplace name reuse means a removed extension’s identifier can be claimed by somebody else, so a name your team once trusted can return attached to different code. And the AI-flavoured VS Code forks that pull from Open VSX have been observed recommending extension identifiers that do not exist in that registry, which effectively publishes a list of names with pre-built demand for anyone willing to fill them.
Every one of those cases used the extension system exactly as documented. The chain from author to your process has more links than the marketplace page suggests:
Read it left to right and count the links you have any visibility into. For most teams the answer is the last one, and only after something has already gone wrong.
The trust model most extension ecosystems actually have
It is worth being blunt about how little verification stands between a published extension and your machine, because the marketplace interface implies more than it delivers.
Publishing is generally open to anyone with an account. Automated scanning catches known malware signatures and obvious behaviours, which is useful against unsophisticated attacks and defeated by anything novel. Human review, where it exists at all, is typically triggered by reports rather than applied to every submission.
Name similarity is barely policed. A popular extension called prettier-vscode can be shadowed by prettier-vs-code, and developers install by searching a name they half-remember. Install counts and star ratings, the signals most people use to judge legitimacy, are cheap to inflate.
Updates are the part that deserves the most attention. Most extension hosts update automatically and silently. An extension you vetted carefully in January can ship entirely different code in June, either because the author sold it, or because their publishing credentials were stolen, or because they decided to monetise. Your original review covered a version that no longer exists.
Editor ecosystems side by side
Teams usually run more than one editor, and the controls available differ enough that a single policy will not cover them. Laying the ecosystems out next to each other shows where the gaps sit.
| Ecosystem | Who can publish | Review before listing | What a loaded extension can do | Update default | Org-level control |
|---|---|---|---|---|---|
| Visual Studio Marketplace | Anyone with an account and a publisher token | Automated scanning, reactive takedown after reports | Full user privileges inside the extension host | Automatic | extensions.allowed policy, from VS Code 1.96 |
| Open VSX | Anyone who signs the publisher agreement | Lighter automated checks | Full user privileges inside the extension host | Automatic in most forks | Depends entirely on the fork |
| JetBrains Marketplace | Account plus plugin submission | Automated checks with some manual review | Full user privileges inside the IDE’s JVM | Automatic | IDE Services profiles, private plugin repository |
| Notepad++ plugin list | Pull request to a curated list repository | Human review of the pull request | Native DLL loaded into the editor process | Prompted via Plugins Admin | Nothing built in; filesystem and app control only |
| Vim and Neovim | Any git host, no registry at all | None | Full user privileges, shell access is normal | Whatever your plugin manager does | Lockfile pinned in the dotfiles repo |
| Sublime Package Control | Pull request to the channel repository | Human review of the pull request | Python with full interpreter access | Automatic | Fork or replace the channel |
| Emacs MELPA | Recipe pull request | Human review of the recipe, not each release | Elisp with full process access | Manual by default | Pin to an internally mirrored archive |
The column worth staring at is the fourth one, because it reads the same all the way down. Every one of these systems hands a loaded extension the full privileges of the editor process, and none of them offers the kind of per-capability consent model that browsers and mobile platforms adopted a decade ago. A theme and a debugger have identical reach once the process starts.
There is a mild surprise in the bottom rows. Vim and Neovim users have no review process whatsoever, which looks like the worst position on the table, and they are frequently in better shape than a VS Code user, because a plugin manager lockfile committed to a dotfiles repository pins every plugin to an exact commit and makes every change a reviewed diff. The ecosystems with the best-looking marketplaces have the worst update hygiene, since the marketplace is doing the updating on the user’s behalf.
The other pattern to draw out of the table is that the curated lists, Notepad++ and Package Control and MELPA among them, review the submission rather than the release. A human reads the pull request that adds the plugin. Nobody reads version 2.4.1 when it lands six months later. Which means the review signal you are relying on describes the plugin’s first day and nothing since.
Reading a manifest before you trust the description
Marketplace pages describe what an extension is for. The manifest describes what it can do, and the two documents are written by the same person with very different levels of care.
A .vsix is a zip archive, and pulling one apart takes about twenty seconds:
# Download without installing. The gallery response is gzip-encoded.
PUB=esbenp; EXT=prettier-vscode; VER=11.0.0
curl -sL --compressed -o "$EXT.vsix" \
"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/$PUB/vsextensions/$EXT/$VER/vspackage"
unzip -oq "$EXT.vsix" -d "$EXT-unpacked"
jq '{name, publisher, version, main, browser,
activationEvents,
extensionDependencies,
extensionPack,
contributes: (.contributes // {} | keys)}' \
"$EXT-unpacked/extension/package.json"
Five fields in that output carry most of the signal, and each of them has a shape that should match the extension’s stated job:
| Manifest field | What it grants | Mismatch worth pausing on |
|---|---|---|
activationEvents: ["*"] or onStartupFinished | Runs on every window, whether or not you use it | A language-specific tool that wakes up for everything |
main present | Executable JavaScript in the extension host | Anything advertised as a theme or icon pack |
contributes.terminal.profiles | Defines shells the editor will launch for you | Formatters, linters, themes |
contributes.debuggers | Registers a debug adapter, which is an arbitrary binary | Tools that do not debug anything |
extensionDependencies / extensionPack | Installs and loads further extensions you did not review | Any entry you cannot name |
Once the manifest looks reasonable, a coarse grep tells you whether the code can start processes and open sockets:
grep -rEn "child_process|\bexec(Sync)?\(|\bspawn(Sync)?\(|https?\.request|net\.connect|Buffer\.from\([^)]*base64" \
"$EXT-unpacked/extension" --include='*.js' | head -40
Be honest about what that produces. Most extensions ship a single webpacked bundle, so the output is noisy and the line numbers are meaningless. The value is a yes or no answer to one question: can this thing run commands and talk to the network? A colour theme that can do both has explaining to do. A language server client that can do both is behaving normally, and you move on.
The same exercise works elsewhere with different tools. JetBrains plugins are zip archives containing a META-INF/plugin.xml that declares extension points and dependencies, readable with unzip -p plugin.zip META-INF/plugin.xml. Notepad++ plugins are compiled DLLs, so the manifest route closes and you fall back to signature status and file provenance. That asymmetry matters for policy: ecosystems shipping source or bundled script can be inspected cheaply by anyone, while native plugin systems push you toward trusting the distribution channel, which is precisely the channel that failed in July.
Reducing the exposure without giving up the tooling
Telling developers to stop using extensions is advice nobody follows, so the practical goal is narrowing what an extension compromise reaches.
Start with an inventory, because most developers have accumulated extensions they no longer use and cannot name:
# VS Code and forks
code --list-extensions --show-versions
# JetBrains IDEs: plugins live under the config directory
ls ~/Library/Application\ Support/JetBrains/*/plugins 2>/dev/null # macOS
ls ~/.local/share/JetBrains/*/plugins 2>/dev/null # Linux
# Notepad++ on Windows
dir "%ProgramFiles%\Notepad++\plugins" /b
dir "%APPDATA%\Notepad++\plugins" /b
Uninstall what you do not use. This is genuinely the highest-value step, takes five minutes, and typically removes half of them.
Then disable automatic updates and take them deliberately. The cost is a periodic chore; the benefit is that a compromised publisher account does not reach you the same afternoon. In VS Code this is extensions.autoUpdate set to false, with extensions.autoCheckUpdates left on so you still learn that updates exist.
For the extensions that remain, check the publisher rather than the extension. A verified publisher with a long history and a real organisation behind it is a different proposition from an account created two months ago, regardless of how many installs the extension claims.
Turning the inventory into something you can act on
A list of names tells you what is installed. It does not tell you which entries are new, which arrived from outside the marketplace, or which one changed while you were on holiday. Adding three fields to the inventory answers all three questions.
VS Code keeps its installed-extension metadata in a single JSON file, which is far easier to query than walking directories:
# Publisher, version, scope and install time, newest last.
jq -r '.[] | [ .identifier.id,
.version,
(.metadata.publisherDisplayName // "UNKNOWN"),
(.metadata.isMachineScoped // false | tostring),
(.metadata.installedTimestamp // 0 | tostring) ]
| @tsv' ~/.vscode/extensions/extensions.json \
| sort -t$'\t' -k5 -n | column -t -s $'\t'
Field names in that file have shifted between releases, so run jq '.[0]' ~/.vscode/extensions/extensions.json once and check what yours actually contains before wiring it into anything. An entry with UNKNOWN in the publisher column generally means the extension was installed from a local .vsix rather than the gallery, which is worth a question even when the answer is innocent.
For a fleet, hash the manifests and diff week to week. Anything whose hash moved without a version bump deserves a look:
find ~/.vscode/extensions -maxdepth 2 -name package.json -print0 \
| xargs -0 shasum -a 256 \
| sed "s|$HOME/.vscode/extensions/||" \
| sort > ~/ext-manifest-$(date +%F).sha256
diff ~/ext-manifest-2026-07-19.sha256 ~/ext-manifest-2026-07-26.sha256
Windows and Notepad++ need a different approach, since compiled plugins give you nothing to read. Signature status plus write time is what remains, and write time is the more useful of the two:
$paths = @("$env:ProgramFiles\Notepad++\plugins", "$env:APPDATA\Notepad++\plugins")
Get-ChildItem $paths -Recurse -Filter *.dll -ErrorAction SilentlyContinue |
ForEach-Object {
$sig = Get-AuthenticodeSignature $_.FullName
[pscustomobject]@{
Name = $_.Name
Signed = $sig.Status
Subject = $sig.SignerCertificate.Subject
Written = $_.LastWriteTime
SHA256 = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.Substring(0, 16)
}
} | Sort-Object Written -Descending | Format-Table -AutoSize
Plenty of legitimate Notepad++ plugins are unsigned, so the Signed column will not decide anything on its own. Sort by write time instead and read the top three rows. A plugin DLL written last Tuesday, on a machine where you installed nothing last Tuesday, is the row that matters, and it is the exact artefact the July campaign left behind.
Run the whole thing on a schedule rather than in response to news. Monthly is enough for most teams, quarterly is the point at which people stop recognising their own extension lists, and the value of the exercise is almost entirely in the diff rather than the snapshot.
Pinning versions so that an update is a decision
Switching auto-update off buys you time and nothing more, because the update still arrives the moment somebody clicks the button in the extensions panel. Pinning removes the decision from the moment of temptation.
The path a silent update takes is short enough to draw:
VS Code accepts an allowlist in settings, and the same structure works whether it is deployed by policy or committed to a shared profile:
{
"extensions.autoUpdate": false,
"extensions.autoCheckUpdates": true,
"extensions.ignoreRecommendations": true,
"extensions.allowed": {
"dbaeumer.vscode-eslint": ["3.0.10"],
"esbenp.prettier-vscode": ["11.0.0"],
"rust-lang.rust-analyzer": ["5.0.0@darwin-arm64", "5.0.0@win32-x64"],
"github.vscode-pull-request-github": "stable",
"ms-python": true,
"*": false
}
}
Four behaviours in that block are worth knowing before you copy it. A true value against a bare publisher name allows everything that publisher ships. An array pins exact versions, and version ranges are not supported, so the list is literal strings you maintain by hand. The @platform suffix pins per architecture, which matters for extensions with native components. And "*": false flips the default to deny, which is the only line in the block that turns an allowlist into an allowlist.
Repository-level configuration is worth adding for the developer experience, provided nobody mistakes it for a control:
// .vscode/extensions.json
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
"unwantedRecommendations": ["hookyqr.beautify"]
}
That file shapes a prompt when somebody opens the repository. It blocks nothing at all. The enforcing version of the same idea lives in a dev container, where the extension list becomes part of the image and pins go with it:
{
"customizations": {
"vscode": {
"extensions": ["[email protected]", "[email protected]"]
}
}
}
Here is how the options compare on what they actually prevent:
| Control | Blocks a bad new install | Blocks a bad update | Blocks sideloading | Ongoing cost |
|---|---|---|---|---|
extensions.autoUpdate: false | No | Delays it until someone clicks | No | Minutes per month |
| Allowlist by publisher | Partly | No | Yes, when policy-deployed | Low |
| Allowlist with exact versions | Yes | Yes | Yes, when policy-deployed | A recurring bump queue |
Internal .vsix mirror | Yes | Yes | Yes | A pipeline someone owns |
| Dev container extension list | For container work only | For container work only | For container work only | Low if you already use them |
.vscode/extensions.json | No | No | No | Nearly nothing |
An internal mirror is the heaviest row and the only one that also survives the marketplace itself having a bad day. Fetch approved .vsix files in CI, store them with checksums, and install from disk with code --install-extension ./vsix/dbaeumer.vscode-eslint-3.0.10.vsix --force. Teams already running an artefact repository for npm or Maven have most of the machinery.
What the editor gives you, and where it stops
VS Code does ship one genuine containment feature, and most developers have clicked past it without reading. Workspace Trust decides whether opening a folder is allowed to run anything from that folder, which covers the case of cloning a repository to review it and finding that the repository’s own tooling executed first.
The settings are worth setting deliberately rather than accepting:
{
"security.workspace.trust.enabled": true,
"security.workspace.trust.startupPrompt": "once",
"security.workspace.trust.untrustedFiles": "prompt",
"security.workspace.trust.emptyWindow": false,
"security.workspace.trust.banner": "always"
}
In restricted mode, extensions that declare themselves untrusted-workspace-capable keep running with reduced function, tasks and debug configurations from the folder are blocked, and workspace-scoped settings that could execute something are ignored. Reviewing a pull request from a stranger, or opening a repository you found in a search result, is exactly the moment this earns its keep.
Understand the boundary, though. Workspace Trust protects you from the folder. It does nothing about the extension, which runs with full privileges in a trusted workspace and which you already installed voluntarily. An extension declaring "untrustedWorkspaces": { "supported": true } in its manifest runs in restricted mode too, and that declaration is made by the extension author.
The other feature people over-read is the extension host process boundary. Extensions run in a separate process from the editor’s UI, which is a stability measure that stops a crashing extension taking the window down. That process runs as you, reads what you can read, and opens whatever sockets it likes.
Two adjacent settings are worth pinning at the same time, since both change what runs without a decision from you:
| Setting | Recommended value | What changes |
|---|---|---|
extensions.autoUpdate | false | Updates require a click instead of arriving overnight |
extensions.autoCheckUpdates | true | You still learn that updates exist |
extensions.ignoreRecommendations | true | The editor stops suggesting extensions based on file types you open |
security.workspace.trust.enabled | true | Unknown folders open in restricted mode |
git.allowNoVerifyCommit | false | Hooks cannot be skipped silently from the UI |
terminal.integrated.allowWorkspaceConfiguration | false | A repository cannot define the shell your terminal launches |
That last row deserves attention out of proportion to its obscurity. Workspace-level terminal configuration lets a cloned repository specify what runs when you open a terminal in it, and opening a terminal is not a decision anyone treats as consequential.
None of these settings addresses the core problem, and all of them narrow the paths that do not require compromising a publisher first. Set them once in your user profile, or push them by policy, and stop thinking about them.
What a malicious extension does with the access
Being concrete about the objective clarifies which defences matter, because the plausible outcomes differ enormously in cost.
Credential harvesting is the immediate one and the least interesting. The extension reads ~/.ssh, ~/.aws, ~/.config/gcloud, .npmrc, .pypirc, and any .env in the workspace, then sends them out. The damage is bounded by what those credentials reach and how quickly you rotate, which is why credential lifetime is the control that changes the outcome.
Source code exfiltration is next. An extension has the repository open already, and copying it is neither difficult nor conspicuous. For most companies this is a serious but survivable loss.
Code modification is the one worth losing sleep over. An extension can alter files between the moment you write them and the moment you commit, or alter what the editor displays so that the file on disk differs from what you see. A subtle change introduced this way arrives in a pull request under your name, with your signature if you sign commits, and reviewers evaluate it as your work. The standard defences against malicious commits all assume the developer’s machine is telling the truth.
The practical mitigation for that last case is reviewing diffs somewhere other than the machine that produced them. Reading a pull request in a browser on a different device, or simply on the hosting platform’s web interface rather than in the editor, means the compromised component is not the one rendering the change. That costs nothing and it is the only defence in this list that addresses the worst case.
Judging an extension before installing it
The decision happens in about fifteen seconds in practice, so the checks need to be fast enough to fit in that window.
Look at the publisher rather than the extension name. A verified publisher badge means the marketplace confirmed domain ownership, which is a low bar and still filters out the casual impersonation. An extension with a million installs from an unverified account created recently is a stronger warning than a small extension from a known organisation.
Check that the repository link works and contains the code the extension claims. A surprising number of malicious extensions link to a legitimate project’s repository while shipping something else entirely, on the assumption that nobody clicks through. Nobody does, and clicking through takes five seconds.
Read the requested permissions against the stated function. A theme requesting network access, or a formatter requesting the ability to run shell commands, is a mismatch worth pausing on. Editors vary in how clearly they surface this, and the manifest is always readable in the repository.
Prefer extensions that many people at your company already use. Concentration is a defence here: an extension in wide internal use is one where a compromise is likely to be noticed by somebody, whereas an extension only you have installed has an audience of one.
The sixty-second version of that check
Fifteen seconds is the budget for an extension you will use once. Anything heading for a shared allowlist, or anything that will sit in the extension host of forty machines, earns a longer look, and a minute is enough if you know which minute to spend.
The decision has a shape:
Most of the answers are available from the command line faster than they are from a browser, and Open VSX exposes a public API that is far easier to query than the Visual Studio Marketplace one. For the many extensions published to both, it answers the same questions:
EXTID="esbenp.prettier-vscode"
# Already installed anywhere on this machine?
code --list-extensions --show-versions | grep -i "$EXTID"
# Publisher, age, licence, repository, release cadence
curl -s "https://open-vsx.org/api/${EXTID/./\/}" \
| jq '{namespace, name, version, timestamp, downloadCount,
repository, license, allVersions: (.allVersions | length)}'
Four numbers in that output do the work. A timestamp from last month against a large downloadCount is a contradiction worth investigating. An allVersions count of one means nobody has maintained this through a breaking change. A missing repository field removes your ability to check anything at all, which is reason enough to stop. A missing license says the author did not fill in the form, which correlates with the other things they did not do.
The remaining seconds go on the repository itself. Open the linked repo, look at the commit history for the past year, and check that the extension identifier in its README matches the one you are about to install. A surprising share of malicious extensions link to a legitimate project on the assumption that nobody clicks, and the click costs five seconds.
Two habits make the minute cheaper over time. Search for the tool first, find its repository, then install from the identifier printed in its own documentation, rather than searching the marketplace and trusting the top result; ranking is influenced by install counts and install counts are purchasable. And treat “three people on my team already run this” as real evidence, because a shared extension has several chances of somebody noticing odd behaviour, while an extension only you installed has an audience of one and no baseline.
Write the outcome somewhere durable. A short line in a team document, with the extension identifier, the version you approved, the date, and who looked, prevents the same evaluation being redone badly by somebody in a hurry six weeks later. Approval decisions that live only in someone’s memory expire quietly.
The controls that work at organisation scale
Individual discipline decays, so teams need something structural.
An allowlist enforced by policy is the strongest option and is available in most enterprise editor deployments. Maintaining it is real work and the list stabilises quickly in practice, because the set of extensions a team genuinely needs is smaller than the set installed.
Where an allowlist is too heavy, egress control on developer machines catches the payload stage. The Notepad++ campaign needed to reach its command-and-control infrastructure, and a workstation that can only reach approved destinations turns a successful installation into a failed one. This is more disruptive on developer machines than elsewhere, since developers legitimately reach a wide range of hosts, and a monitored-with-alerting posture is often the realistic compromise.
The control that matters most is keeping production credentials off developer workstations entirely. If the highest-value thing an extension can steal is a short-lived token scoped to a staging environment, the whole category drops from critical to annoying. That is a larger programme than an extension policy, and it pays out against every other workstation compromise as well.
Rolling out an allowlist without a revolt
Allowlists fail politically far more often than technically. The pattern that survives contact with a development team runs in four phases and takes about six weeks, most of which is waiting.
Measure first, for two weeks. Collect the inventory described earlier from every machine and count. The distribution is always the same shape: a couple of dozen extensions that most people have, a long tail installed by one person each, and three or four surprises nobody can account for. Publish the frequency table before you propose any policy, because the table makes the argument for you.
Draft from the data, not from opinion. Anything with five or more users goes on the list at its current version. Anything in the tail gets an owner who wants it or gets dropped. Resist writing the list from what people ought to need; the point of the measurement phase is to avoid exactly that.
Announce before you enforce. Ship the list as a document and an alert, so deviations produce a Slack message rather than a broken workflow. Two weeks of that surfaces the cases your data missed, and the person who discovers a gap becomes an ally rather than a complainant.
Then enforce, with an exception queue that answers within two working days. A queue with a two-day turnaround keeps the policy alive. A queue with a two-week turnaround gets the policy deleted by an executive after the third complaint, and you will not get a second attempt.
On Windows, the policy deploys through Intune or Group Policy, and the value is a JSON string in a registry key:
$allow = @{
"dbaeumer.vscode-eslint" = @("3.0.10")
"esbenp.prettier-vscode" = @("11.0.0")
"github" = $true
"ms-python" = $true
"*" = $false
} | ConvertTo-Json -Compress
$key = "HKCU:\SOFTWARE\Policies\Microsoft\VSCode"
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name "AllowedExtensions" -Value $allow -Type String
On macOS the same policy arrives as a managed preference from your MDM, carrying the same JSON payload. Check your MDM’s documentation for the preference domain it expects, since the details have shifted as the policy support matured, and verify on one machine before pushing to the fleet.
JetBrains takes a different route. IDE Services profiles let an administrator define plugin scopes with allow and deny rules, mark plugins as mandatory, and push settings to managed IDEs. Teams without IDE Services can get most of the way with a self-hosted plugin repository, which is a single XML file served over HTTPS and added under Settings, Plugins, Manage Plugin Repositories, with the public marketplace disabled:
<!-- updatePlugins.xml, served from an internal host -->
<plugins>
<plugin id="org.example.approved-plugin"
url="https://plugins.internal.example.com/approved-plugin-2.4.1.zip"
version="2.4.1"/>
<plugin id="org.example.other-plugin"
url="https://plugins.internal.example.com/other-plugin-1.8.0.zip"
version="1.8.0"/>
</plugins>
Notepad++ and the git-based editors have no policy layer to configure, so control falls to the operating system. Application control rules that refuse unsigned DLLs from the plugins directory cover the first case. Committed lockfiles in a managed dotfiles repository cover the second.
One caveat on all of it. Treat the allowlist as a strong default rather than a boundary. There have been reported cases of installation paths that the VS Code policy did not cover, and sideloading a .vsix from disk is the obvious one to keep watching. Policy tells you what should be installed. Telemetry tells you what is, and you need both.
Detection worth building
Two behaviours distinguish a malicious extension from a normal one clearly enough to alert on.
The first is the editor process spawning unexpected children. Editors legitimately spawn language servers, formatters, linters, and shells, so the baseline is noisy. Spawning powershell.exe with an encoded command, or a process from a temporary directory, or an archive utility unpacking into %APPDATA%, is outside that baseline and worth an alert.
The second is file access outside the workspace. An extension reading ~/.ssh/id_ed25519 or ~/.aws/credentials has no legitimate reason to do so, and endpoint tooling that can attribute file access to a process will show it. Building a detection around access to a small set of high-value credential paths, from any process that is not the tool that owns them, catches this campaign and a great many others.
# Quick local check: what has the editor touched recently outside the repo?
# macOS; adapt paths for other systems.
sudo fs_usage -w -f filesys | grep -Ei 'Code Helper|notepad' \
| grep -Ev "$HOME/code|/Applications" | head -40
Writing rules that survive a real developer fleet
Those two behaviours are the right shape. Turning them into rules that stay switched on takes tuning, because a developer workstation is the noisiest endpoint in any organisation and the first false positive storm is usually the last time anyone looks at the detection.
Start with process ancestry, and constrain on the arguments rather than the child process alone, since editors spawn shells all day long:
parent_process IN ("Code.exe", "Code - Insiders.exe", "cursor.exe",
"idea64.exe", "pycharm64.exe", "notepad++.exe",
"sublime_text.exe")
AND child_process IN ("powershell.exe", "pwsh.exe", "wscript.exe", "cscript.exe",
"mshta.exe", "rundll32.exe", "regsvr32.exe",
"certutil.exe", "curl.exe", "7z.exe", "rar.exe", "unrar.exe")
AND (
command_line MATCHES "(?i)-e(nc|ncodedcommand)?\s+[A-Za-z0-9+/=]{40,}"
OR command_line MATCHES "(?i)(invoke-webrequest|iwr|downloadstring|frombase64string)"
OR command_line MATCHES "(?i)\s-(p|hp)\S{4,}" // archive password on the command line
OR child_path MATCHES "(?i)\\(temp|appdata\\local\\temp)\\"
)
That last archive-password clause is what would have fired in July. A password-protected RAR is invisible to scanners, and the password has to travel with the loader, which means it shows up on a command line or in a process’s memory. Opacity to scanning costs the attacker visibility somewhere else.
The credential-path rule needs the opposite treatment, scoped by path rather than by process name, because the process doing the reading will very often be node running under the editor’s helper, which is also what every legitimate language server looks like:
file_read_path STARTS_WITH ANY (
"~/.ssh/", "~/.aws/", "~/.config/gcloud/", "~/.kube/config",
"~/.docker/config.json", "~/.npmrc", "~/.pypirc", "~/.netrc",
"~/Library/Application Support/Code/User/globalStorage/",
"%APPDATA%\Code\User\globalStorage\"
)
AND process_ancestry CONTAINS ANY ("Code", "Code Helper", "idea", "notepad++")
AND process_name NOT IN ("ssh", "ssh-add", "git", "git-credential-osxkeychain",
"aws", "gcloud", "kubectl", "docker", "npm", "pip")
On Windows, Sysmon gives you both signals cheaply, and the second rule group is the one aimed directly at the Notepad++ case:
<Sysmon schemaversion="4.90">
<EventFiltering>
<RuleGroup groupRelation="and">
<ProcessCreate onmatch="include">
<ParentImage condition="end with">Code.exe</ParentImage>
<Image condition="contains any">powershell.exe;wscript.exe;mshta.exe;regsvr32.exe;certutil.exe</Image>
</ProcessCreate>
</RuleGroup>
<RuleGroup groupRelation="and">
<ImageLoad onmatch="include">
<ImageLoaded condition="contains">\Notepad++\plugins\</ImageLoaded>
<Signed condition="is">false</Signed>
</ImageLoad>
</RuleGroup>
</EventFiltering>
</Sysmon>
For scheduled inventory rather than real-time alerting, osquery covers the fleet in one query and gives you the diff for free:
-- Extension manifests written in the last day, across all users
SELECT u.username, f.path, datetime(f.mtime, 'unixepoch') AS changed, h.sha256
FROM users u
JOIN file f ON f.path LIKE u.directory || '/.vscode/extensions/%/package.json'
JOIN hash h ON h.path = f.path
WHERE f.mtime > (strftime('%s', 'now') - 86400);
Network telemetry rounds it out, and the useful query is first-seen rather than blocklist-based. An editor process resolving a domain nobody in the organisation has resolved before, within an hour of an extension install or update, is a small enough set of events to review by hand.
Run all of it in report-only mode for a fortnight before anyone gets paged. Build the exclusion list from what your own toolchain genuinely does, which will include several things that look alarming until you trace them, and keep the exclusions narrow: exclude a specific parent-and-child pair with a matching argument shape, never a whole directory and never the editor itself.
Anti-patterns that keep turning up
Most of the failures in this area are not exotic. They repeat across organisations with the same reasoning behind them each time, which makes them easy to check for in your own setup.
Treating install count as review. The two AI assistant extensions that were shipping developer files abroad had around 1.5 million installs between them. Popularity measures marketing and time, and a compromised update inherits every install the honest versions earned.
Vetting once, then leaving auto-update on. A careful review of version 3.0.10 says nothing about 3.0.11, which will arrive without a prompt. Either pin the version or accept that your review has a shelf life of days.
Allowlisting by publisher and stopping there. A publisher-level allow is a standing permission for anything that publisher’s credentials can push, including anything pushed by whoever stole them. It is a sensible first week and a poor permanent position.
Blocking the marketplace without providing a mirror. Developers who need a tool will find it, and the version they find will arrive as a .vsix from a download link with no telemetry attached. A monitored install path was converted into an unmonitored one.
Assuming themes are inert. Nothing in the packaging format prevents a theme from shipping executable code, and a theme is the least-reviewed thing anybody installs.
Reading the verified badge as an audit. Verification confirms control of a domain. It says nothing about the security of the publisher’s build pipeline or the storage of their publishing token, which are the two things that failed in every recent incident.
Excluding editor directories from endpoint scanning. This one arrives as a performance complaint and gets approved quickly, because the editor really is slow with real-time scanning enabled on a large workspace. The result is that the directory containing third-party code loaded into a trusted process becomes the one directory your tooling ignores. Scope the exclusion to the workspace, never to the extensions directory.
Keeping long-lived cloud credentials on the workstation because it is only development. The credential does not know it is only development, and neither does the extension reading it.
Reviewing diffs in the editor that produced them. Covered above, and worth listing here because it survives every other control on this page.
One more, less obvious than the rest: assigning extension policy to the security team alone. The list has to be maintained by people who feel the pain of a missing tool, which means a named owner on the development side who can approve additions the same week. Policies maintained exclusively by people who do not use the tooling develop a backlog, and a backlog is how a policy quietly stops being followed.
The surface is growing, not shrinking
Everything above describes the extension problem as it has existed for a decade. The reason to fix it now is that the same trust model has been extended to a new class of component with far broader reach.
AI coding assistants arrive as extensions, and they ask for more than a formatter does by design. Reading the whole workspace is their function. Many of them also run tool servers, spawn processes on the developer’s behalf, and hold their own credentials for the model provider, which puts a long-lived API key in the same directory as everything else worth stealing. The Nx Console payload specifically went looking for credentials belonging to AI assistants, which tells you the category is already on the target list.
Tool servers built on the Model Context Protocol extend the chain by another link. A server is a process the assistant starts, often installed with npx from a package name in a configuration file, with the same privileges as everything else on the workstation. The install path has no marketplace, no review, and no verified publisher badge, which removes even the weak signals discussed earlier. Configuration files listing these servers deserve the same treatment as a lockfile: pinned versions, reviewed changes, and no additions that arrive with a pull request nobody read closely.
The forks are the other growth area. Cursor, Windsurf, and the rest inherit the VS Code extension model and pull from Open VSX, which has a lighter review process than the Microsoft marketplace and, as the research showed, weaker defaults around publishing token expiry. Extension identifiers recommended by these forks but absent from that registry are unclaimed names with waiting demand, and claiming one costs nothing.
Three practical consequences follow. Treat an AI assistant extension as the highest-privilege thing on the machine and pin it hardest. Keep the model provider API key out of a dotfile and in the operating system keychain if the tool supports it. And apply the same allowlist thinking to tool server configuration that you apply to extensions, because the configuration file is currently the least-reviewed file in most repositories and it names executables that will run on every developer’s machine.
The first hour after a bad extension
Assume it happens. A vendor advisory names an extension your inventory shows on nine machines, and the useful question becomes what to do in the next sixty minutes, in what order.
Capture before you clean, because the extension directory is the only evidence you will get and uninstalling deletes it:
# 1. Preserve, then list
tar czf ~/ext-evidence-$(date +%s).tgz ~/.vscode/extensions
code --list-extensions --show-versions > ~/ext-list-$(date +%s).txt
# 2. Stop the editor, then remove by identifier
pkill -f 'Visual Studio Code' 2>/dev/null
code --uninstall-extension publisher.extension-name
Rotation comes next, and the order should follow blast radius rather than convenience:
| Credential | Where it lives | Priority |
|---|---|---|
| Cloud access keys | ~/.aws/credentials, ~/.config/gcloud | Immediately, then read the audit log for use from unfamiliar addresses |
| Git provider tokens | ~/.git-credentials, keychain, .netrc | Immediately, then review recent pushes across all repositories |
| Package registry tokens | ~/.npmrc, ~/.pypirc | Immediately, then check whether any version was published |
| SSH keys | ~/.ssh/id_* | Same day, including every authorized_keys the key reached |
| Editor session tokens | the editor’s globalStorage | Invalidate sessions rather than rotating passwords |
The step most teams skip is checking what was written rather than what was read. An extension with file access could have modified code, and a modified commit is worth more to an attacker than a stolen key:
# Anything this machine committed during the exposure window
for g in $(find ~/code -maxdepth 3 -name .git -type d); do
repo=$(dirname "$g")
git -C "$repo" log --since="2026-07-01" --oneline --all 2>/dev/null \
| sed "s|^|${repo##*/}: |"
done
# Git hooks are a favourite persistence spot and almost nobody looks at them
find ~/code -path '*/.git/hooks/*' -type f ! -name '*.sample' -newermt '-30 days'
Then rebuild the machine rather than cleaning it. Removing the extension removes the extension, and says nothing about what it installed in the forty minutes it was running. A workstation rebuild is a few hours of inconvenience with a well-understood outcome, and the alternative is carrying an open question on that endpoint indefinitely. Teams that keep their development environment in a dev container or a documented setup script find this decision much easier to make, which is a decent argument for doing that before you need it.
The uncomfortable summary
The vendor’s position that plugin loading is intended functionality is correct, and it means there is no patch coming for this class of problem. An extension system is a documented feature that runs third-party code inside a trusted process, and every editor with a healthy ecosystem has one.
Which leaves the decision with you rather than with the vendor. Know what is installed, control when it changes, and make sure the credentials within reach of your editor are ones you could rotate on a Tuesday without a war room. The Notepad++ campaign is a reminder that the software supply chain includes the tools you write code in, and that those tools are frequently the least reviewed thing on the machine.