Home/Blog/Post

One monorepo, many supply chains — discovered, not declared

A monorepo is N independent projects sharing a repo. Upwarden finds each one's dependency footprint from what it actually pulled through the firewall — no package.json parsing, no manual setup.

Ask a supply-chain tool “what does this repository depend on?” and you usually get one answer: a single flattened list for the whole repo. For a monorepo, that answer is wrong in a way that matters.

A monorepo isn’t one project. It’s a checkout service, a data pipeline, an internal SDK, a web app — each with its own dependency footprint, its own blast radius, its own owner. services/checkout pulling a compromised lodash is a different incident from tools/scripts doing the same. Collapse them into one list and you lose exactly the resolution an operator needs: which part of my codebase actually touched the bad thing?

The usual answer: parse the manifests

The obvious way to recover per-package structure is to read it out of the repo — walk the package.json / pyproject.toml / pom.xml files, reconstruct the workspace graph, and map dependencies back to packages. It works, sort of, until it doesn’t: workspace tools disagree on layout, generated and vendored code lies, private registries hide edges, and the declared manifest is not the resolved, installed set. You end up maintaining a parser per ecosystem to approximate something you still can’t fully trust.

We don’t do that.

Discovered from traffic, not declared

Every install in your CI already crosses Upwarden — that’s the whole point of a registry firewall. So instead of asking the repo to describe itself, we watch what it does.

When a CI job installs through the proxy, its request carries a short-lived, keyless OIDC identity minted by the CI provider. Upwarden verifies it and reads the attested claims — repository, ref, commit, workflow, run_id. That ties every fetch to a specific run, and the fetch paths reveal which independent part of the repo asked for what. The seams between your units show up on their own, because different units pull different things. No package.json parsing. No manifest to keep in sync. No per-repo setup beyond the two-line drop-in. The structure is discovered from observed traffic, not declared.

There’s an honesty cost to being traffic-driven, and we’d rather state it than paper over it: this is what a run pulled, not a lockfile. A unit appears once a verified run has installed through the firewall; a cold cache or an un-wired tool means less crosses the wire. So the per-unit view is a trustworthy lower bound — “most observed,” never “complete.” It’s an audit record of real traffic, not a bill of materials. For catching what actually reached a build, a faithful record of reality beats an aspirational declaration.

What falls out once you can see the units

Once the units are visible, per-package supply-chain visibility stops being a project and starts being a side effect. Open a unit and you get its run-scoped audit lens — everything that one CI run pulled through the firewall, blocked-first, with every block traced to the CVE or advisory that stopped it:

  • services/checkout pulled lodash@4.17.11blocked, CVE-2021-23337
  • packages/api pulled ws@7.4.5blocked, CVE-2024-37890
  • apps/web pulled react@18.3.1 → served

Not “somewhere in this repo, something was blocked.” This unit, this run, this version, this CVE. A verdict you can follow to a citation, scoped to the part of the codebase that actually owns it.

The monorepo was always many supply chains. The only question was whether your tooling could see the seams — and whether it had to be told where they were, or could just watch, and know.


See the per-unit lens in the dashboard docs, or the model behind it in units and run scope.

← All posts