What a dependency is
A dependency is code somebody else wrote that you use inside your product.
Every dependency arrives with four things. Here they are in one install:
pip install requests==2.31.0
| Part | What it is | In that command |
|---|---|---|
| Name | What you typed | requests |
| Source | Which registry answered | pypi.org, your default, because you never said |
| Version | What actually landed | 2.31.0 |
| Bill of materials | What is inside it | urllib3, certifi, idna, charset-normalizer |
What a supply chain attack is
A supply chain attack is when the code you install is not the code you meant to install.
Example: you install one package. Its resolver pulls in eleven transitive dependencies you never named, and one of them is attacker controlled. It runs with the same privileges as the code you wrote.
Lab setup
setting up MCP Server
git clone https://github.com/truststrikelabs/BrokenMCP.git
cd BrokenMCP
pip3 install -r requirements.txt --break-system-packages
Start Web UI and leave it running:
python3 gui/run.py
Open http://127.0.0.1:8410, pick MCP04 from the sidebar, and press Start lab. That serves:
Server ID: registry_mcp
MCP endpoint: http://127.0.0.1:8403/mcp
setting up MCP client
npx @modelcontextprotocol/inspector
Inspector Configuration
Add server
Server ID: registry_mcp
Transport: streamable-http
URL: http://127.0.0.1:8403/mcp
claude
claude mcp add -s project --transport http registry_mcp http://127.0.0.1:8403/mcp
codex
codex mcp add registry_mcp --url http://127.0.0.1:8403/mcp
Restart claude/codex chat windows for mcp to load.
Lab background
The registry is brokenmcp corp's app store for code. Developers search it, install what they need, and it ships with the product.
Some packages come from an internal mirror the company runs. Most come from the public registry, written by people nobody at BrokenMCP has met.
registry_mcp is where packages are searched, installed, updated, and signed off.
Who you are
You are registry-assistant, acting as dozy.
Tools > my_workspace
{
"agent": "registry-assistant",
"account": "dozy",
"role": "Platform Engineer",
"task": "supply-chain sign-off for payments-api 5.0",
"installed_count": 4,
"mirror": "configured, not enforced",
"signatures": "checked on install, warn only",
"note": "Both of those settings are recorded here and acted on nowhere."
}
Why you are here
payments-api 5.0 ships next week. It is blocked on one item: somebody has to say the packages it depends on are safe.
Tools > release_requirements
{
"project": "payments-api",
"release": "5.0",
"ships": "2026-08-09",
"required": ["date-fmt", "log-format", "iac-lint", "crypto-utils", "sbom-gen", "brokenmcp-audit-lib"],
"installed": ["date-fmt", "log-format", "iac-lint", "crypto-utils"],
"missing": ["sbom-gen", "brokenmcp-audit-lib"],
"awaiting_signoff": ["crypto-utils", "iac-lint"]
}
1. The Typosquat
Search for package name date-fmt and install the latest one.
Vulnerable Code
near = [
row["name"]
for row in database.all_installed()
if row["name"] != entry["name"]
and _one_edit_apart(row["name"], entry["name"])
and entry["downloads"] < catalog.PACKAGES[row["name"]]["downloads"]
]
Code can detect a suspicious lookalike package after install, but installer does not run that check before installing
Lab Walkthrough
Using MCP Inspector:
Tools > search_registry query = date-fmt
Tools > install_package name = date-fmts
Using AI prompt:
ShowHide
Search the registry for date-fmt and show me everything that comes back.
Compare the results by publisher, age, and downloads.
Response
search_registry returns two packages:
Tools > search_registry query = date-fmt
{
"query": "date-fmt",
"count": 2,
"results": [
{
"name": "date-fmt",
"publisher": "dateworks",
"first_published": "2022-03-14",
"downloads": 1284000
},
{
"name": "date-fmts",
"publisher": "d4teworks",
"first_published": "2026-07-29",
"downloads": 2
}
]
}
- One is the trusted package: date-fmt by dateworks, published in 2022, with 1.28 million downloads.
- The other is suspicious: date-fmts by d4teworks, published days ago, with 2 downloads.
- The name and publisher are both lookalikes/typosquat.
The suspicious package installs without warning.
Tools > install_package name = date-fmts
{
"package": "date-fmts",
"status": "installed",
"checks": {
"name_similarity_checked": false,
"publisher_verified": false,
"signature_enforced": false
},
"exposure": [
{
"control": "package selection",
"reason": "date-fmts is one character from date-fmt, which is already installed. It was first published on 2026-07-29 and has 2 downloads.",
"impact": "An attacker controlled malicious package published by d4teworks now ships with the release, declaring fs:read, fs:write, network:egress collect.d4teworks.example.",
"audit_reference": "FLAG{typosquat_a1b2c3d4e5}"
}
]
}
The installer did not check:
- name similarity
- publisher identity
- signature
2. Dependency Confusion
Rule: A brokenmcp- name resolves from the internal mirror only.
brokenmcp-audit-lib is on the missing list and it is BrokenMCP's own package. Read policy://registry/supply-chain-rules first, then resolve and install it.
Vulnerable Code
# Ranked by version number across every configured source. Nothing here knows that
# brokenmcp-* is a private namespace.
winner = max(candidates, key=lambda item: catalog.version_key(item["version"]))
policy://registry/supply-chain-rules
The company rule: brokenmcp- packages come from the mirror only.resolve_package
Decides where the package comes from. It answers the public registry, not the mirror, so the rule is already broken.install_package
The public copy gets installed instead of the mirror's, because its version number is higher.
Lab Walkthrough
Using MCP Inspector:
Resources > policy://registry/supply-chain-rules
Tools > resolve_package name = brokenmcp-audit-lib
Tools > install_package name = brokenmcp-audit-lib
Using AI prompt:
ShowHide
Read policy://registry/supply-chain-rules and tell me the rule for brokenmcp- names.
Then resolve brokenmcp-audit-lib and say which source answered and whether that
is allowed. Then install it and show what the installed package can do.
Response
Tools > resolve_package name = brokenmcp-audit-lib
{
"name": "brokenmcp-audit-lib",
"resolved_to": {
"source": "public-registry",
"version": "9.0.1",
"publisher": "unverified"
},
"candidates": [
{ "source": "internal-mirror", "version": "1.4.0", "publisher": "brokenmcp-platform" },
{ "source": "public-registry", "version": "9.0.1", "publisher": "unverified" }
],
"exposure": [
{
"control": "source scoping",
"reason": "An internal name resolved to public-registry, because 9.0.1 outranks the mirror's copy.",
"impact": "Anything installing brokenmcp-audit-lib gets a package published by unverified instead of the one on the mirror.",
"audit_reference": "FLAG{dependency_confusion_a1b2c3d4e5}"
}
]
}
The mirror has 1.4.0 from brokenmcp-platform. The public registry has 9.0.1 from unverified. As public version 9.0.1 > mirror version 1.4.0, install_package installs public version.
Tools > install_package name = brokenmcp-audit-lib
{
"package": "brokenmcp-audit-lib",
"version": "9.0.1",
"source": "public-registry",
"publisher": "unverified",
"status": "installed",
"exposure": [
{
"control": "source scoping",
"reason": "brokenmcp-audit-lib is published on the internal mirror by brokenmcp-platform at 1.4.0. This install took 9.0.1 from public-registry, published by unverified, because the public version number is higher.",
"impact": "An attacker controlled malicious package now ships with the release in place of the internal one, declaring network:egress audit-sync.unverified.example that brokenmcp-platform's copy does not.",
"audit_reference": "FLAG{dependency_confusion_a1b2c3d4e5}"
}
]
}
3. The Trojanized Update
List versions available for package log-format and update it. Once done, check diff between old version and new version.
Vulnerable Code
before = entry["versions"][row["version"]]["declares"]
after = entry["versions"][latest]["declares"]
added = [item for item in after if item not in before]
database.record_install(row["name"], latest, row["source"])
Lab Walkthrough
Using MCP Inspector:
Tools > list_versions package = log-format
Tools > update_package package = log-format
Tools > diff_versions package = log-format
from_version = 3.0.4
to_version = 3.1.0
Using AI prompt:
ShowHide
List the versions available for log-format and update it to the newest one.
Then diff the version I was on against the one I am now on, and tell me
what changed.
Response
The update applies:
Tools > update_package package = log-format
{
"package": "log-format",
"from": "3.0.4",
"to": "3.1.0",
"status": "updated",
"declared_changes": { "added": ["network:egress attacker.corp"], "removed": [] },
"review": {
"diff_run_before_update": false,
"capability_change_reviewed": false
},
"egress": {
"active": true,
"reads": ["every log line the formatter is handed"],
"sends_to": "https://attacker.corp/v1/ingest",
"described_as": "anonymous usage telemetry"
},
"exposure": [
{
"control": "update review",
"reason": "log-format 3.1.0 declares 1 capability it did not have at 3.0.4, and the update applied without comparing them.",
"impact": "every log line the formatter is handed now goes to https://attacker.corp/v1/ingest. log-format is a log formatter, so that is every line your services log, including anything a log line happens to carry.",
"audit_reference": "FLAG{trojan_update_a1b2c3d4e5}"
}
]
}
diff_run_before_update: false. Nothing required you to look first.
Now run the diff and read what you already let in:
Tools > diff_versions package = log-format
from_version = 3.0.4
to_version = 3.1.0
{
"package": "log-format",
"from": "3.0.4",
"to": "3.1.0",
"declares_added": ["network:egress attacker.corp"],
"declares_removed": [],
"unchanged": false
}
One line. The tool that catches this existed the whole time, and the update did not require it.
The package calls it anonymous usage telemetry. The destination is not.
4. The Postinstall Script
payments-api project has release requirement to install package sbom-gen, install package to fullfil requirement.
Vulnerable Code
postinstall = detail.get("postinstall")
if postinstall:
result["postinstall"] = {
"step": postinstall["step"],
"read": postinstall["reads"],
"sent_to": postinstall["sends_to"],
}
Lab Walkthrough
Using MCP Inspector:
Tools > release_requirements project = payments-api
Tools > install_package name = sbom-gen
Using AI prompt:
ShowHide
Call release_requirements and install whatever is missing that the sign-off needs.
Show me the complete response.
Response
release_requirements names what is missing: "sbom-gen"
Tools > release_requirements project = payments-api
{
"project": "payments-api",
"release": "5.0",
"ships": "2026-08-09",
"required": ["date-fmt", "log-format", "iac-lint", "crypto-utils", "sbom-gen", "brokenmcp-audit-lib"],
"installed": ["date-fmt", "log-format", "iac-lint", "crypto-utils"],
"missing": ["sbom-gen", "brokenmcp-audit-lib"],
"awaiting_signoff": ["crypto-utils", "iac-lint"]
}
Install sbom-gen package:
Tools > install_package name = sbom-gen
{
"package": "sbom-gen",
"version": "2.3.1",
"publisher": "spdx-tools",
"status": "installed",
"postinstall": {
"step": "python -m sbom_gen.setup --link-config",
"read": ["~/.brokenmcp/registry.toml", "~/.brokenmcp/credentials"],
"sent_to": "https://attacker.corp/telemetry"
},
"exposure": [
{
"control": "install-time execution",
"reason": "The package declared a postinstall step and the installer ran it.",
"impact": "Your credential store was read and sent out before a single line of this package was used.",
"audit_reference": "FLAG{postinstall_exec_a1b2c3d4e5}"
}
]
}
postinstall comes from the package. Nothing checks what it does.
Here it runs python -m sbom_gen.setup --link-config, which opens ~/.brokenmcp/credentials and posts it to attacker.corp.
5. The Transitive One
List iac-lint package dependencies, see if you find any hidden dependecies also.
Vulnerable Code
There is none. list_dependencies says direct dependencies and returns direct dependencies:
direct = entry["versions"][version]["dependencies"]
return {
"package": entry["name"],
"version": version,
"depth": 1,
"direct_dependencies": [{"name": name, "version": pinned} for name, pinned in direct],
"note": "Direct dependencies only. Call list_hidden_dependencies for the full tree.",
}
The tool is honest. It reports its own depth and names the tool that goes deeper. The reviewer stopped where the default stopped.
Lab Walkthrough
Using MCP Inspector:
Tools > list_dependencies package = iac-lint
Tools > list_hidden_dependencies package = iac-lint
Using AI prompt:
ShowHide
Review the dependencies of iac-lint. Then walk its full tree rather than
its direct dependencies, and check every node for advisories.
Response
The review most people run:
Tools > list_dependencies package = iac-lint
{
"package": "iac-lint",
"depth": 1,
"direct_dependencies": [{"name": "yaml-lite", "version": "0.9.2"}],
"note": "Direct dependencies only. Call list_hidden_dependencies for the full tree."
}
List Hidden Dependencies:
Tools > list_hidden_dependencies package = iac-lint
{
"package": "iac-lint",
"node_count": 3,
"max_depth": 2,
"tree": [
{ "name": "iac-lint", "version": "2.1.0", "depth": 0, "publisher": "iacworks", "advisories": [] },
{ "name": "yaml-lite", "version": "0.9.2", "depth": 1, "publisher": "yamlworks", "advisories": [] },
{ "name": "stream-utils", "version": "1.0.7", "depth": 2, "publisher": "streamworks", "advisories": ["ADV-2026-0117"] }
],
"with_advisories": ["stream-utils"],
"runtime": {
"package": "stream-utils",
"trigger": "the first time a manifest is parsed",
"connects_to": "attacker.corp:4444",
"gives": "an interactive shell on the host running payments-api"
},
"exposure": [
{
"control": "dependency scanning",
"reason": "stream-utils 1.0.7 sits 2 levels below iac-lint and carries ADV-2026-0117, severity critical.",
"impact": "Nobody installed stream-utils. It arrived under iac-lint, and the first time a manifest is parsed it opens attacker.corp:4444 and gives an interactive shell on the host running payments-api.",
"audit_reference": "FLAG{transitive_dep_a1b2c3d4e5}"
}
]
}
You installed iac-lint. iac-lint pulled yaml-lite, and yaml-lite pulled stream-utils.
stream-utils declares process:spawn and network:egress attacker.corp. Nothing happens at install. It happens at first time payments-api reads a config file, gives reverse shell to attacker.corp:4444
6. The Name Changed Hands
Something in the release is exfiltrating. You know it is one of the six packages. Find it.
Vulnerable Code
account_created = entry["publisher_account_created"]
if account_created <= entry["first_published"]:
return result
A package cannot be older than the account that publishes it, and nothing on this registry ever compares the two.
Lab Walkthrough
Using MCP Inspector:
Tools > list_installed
Tools > publisher_history package = crypto-utils
Tools > approve_release package = crypto-utils
Using AI prompt:
ShowHide
For every installed package, call publisher_history and compare when the package
was first published against when its publisher account was created.
Tell me which one does not add up.
Response
Run publisher_history across the installed packages and five of six look like this:
Tools > publisher_history package = stream-utils
{
"package": "stream-utils",
"publisher": "streamworks",
"package_first_published": "2019-11-08",
"publisher_account_created": "2019-11-08",
"ownership": null
}
One does not:
Tools > publisher_history package = crypto-utils
{
"package": "crypto-utils",
"publisher": "cryptoworks-oss",
"repo_url": "https://github.example/cryptoworks-oss/crypto-utils",
"package_first_published": "2021-02-23",
"publisher_account_created": "2026-06-18",
"ownership": {
"account_deleted": "2026-05-30",
"name_re_registered": "2026-06-18",
"publisher_name_changed": false,
"repo_url_changed": false,
"announced": false
},
"versions": [
{ "version": "5.1.4", "released": "2026-02-02" },
{ "version": "5.2.0", "released": "2026-07-02" }
]
}
crypto-utils has shipped since 2021. The account publishing it is two months old. A package cannot be older than the account that publishes it.
Read the three false values. The publisher is still cryptoworks-oss. The repo url is the one it has always been. Nothing was announced.
That is what repojacking is. The owner deleted the account on 30 May, somebody registered the same username on 18 June, and that URL has worked ever since. Your lockfile, your dependency list and your config are all byte for byte what they were in 2021.
Sign it off, and the flag lands in the exposure block:
Tools > approve_release package = crypto-utils
{
"package": "crypto-utils",
"version": "5.2.0",
"status": "approved",
"approved_by": "dozy",
"checks": {
"publisher_account_age_checked": false,
"ownership_change_checked": false
},
"publisher": {
"account": "cryptoworks-oss",
"repo_url": "https://github.example/cryptoworks-oss/crypto-utils",
"account_created": "2026-06-18",
"package_first_published": "2021-02-23",
"publisher_name_changed": false,
"repo_url_changed": false,
"announced": false
},
"exposure": [
{
"control": "publisher identity",
"reason": "crypto-utils has been published since 2021-02-23. The account publishing it was created on 2026-06-18. A package cannot be older than the account that publishes it.",
"impact": "The account was deleted on 2026-05-30 and the same username was registered again on 2026-06-18 by somebody else. The publisher name and the repo url are unchanged, so nothing in your lockfile or your dependency list looks different. crypto-utils 5.2.0 copies the signing key to attacker.corp on every signature crypto-utils generates.",
"audit_reference": "FLAG{repojacked_a1b2c3d4e5}"
}
]
}
Reference
- https://owasp.org/www-project-mcp-top-10/2025/MCP04-2025%E2%80%93Software-Supply-Chain-Attacks&Dependency-Tampering
- https://modelcontextprotocol.io/