Supply-chain SBOM

A Maven coordinate is groupId:artifactId — com.mycorp:auth-lib lives in a namespace nobody else can publish into, so an internal artifact name is safe by construction. PyPI and npm (without a scope) share one flat namespace with the entire public internet: an unscoped name your build resolves internally, like mycorp-auth, is simultaneously up for grabs on the public index. If your resolver — or a misconfigured private registry proxy — ever checks the public index too, an attacker who registers that name first gets your build to install their package. This is dependency confusion, and an SBOM is what you check it against.

CycloneDX SBOMflat components vs. a graph name normalizationtransitive risk

One SBOM, one small service, six components

A CycloneDX document keeps two views of the same dependency tree: components is a flat list — every package the scanner found, at any depth — and dependencies is the edge list between their bom-refs, i.e. the actual graph. The scene below is f1-service's real SBOM: five direct dependencies and one transitive dependency of requests. Pick a component to see whether its name is a confusion risk, and why.

Why the graph, not just the list

mycorp-billing never appears in f1-service's own dependsOn — it is requests's dependency, reached two hops down. A checker that loops over the flat sbom["components"] list would see the name, but it cannot answer the two questions a real finding needs: does this component actually ship (a components list can carry entries nothing reachable from the root pulls in — a stale scan, a dev-only tool, a second root in a merged BOM), and who pulls it in, which is the line you have to change to fix it. Only the graph answers both. The natural first draft walks only the root's direct dependsOn instead of the full reachable set — the root's own edges are the first thing you read off the SBOM — and that bug stays invisible until a risky name happens to sit more than one hop down.

Takeaways: a confusion risk needs both an internal-looking normalized name and an existing public listing — either alone is noise. Normalize before comparing on both sides (case, -/_/. folded to one separator) and match a true prefix, never a substring, or you flag public packages that merely contain the word and miss internal names spelled with different separators. And walk the whole reachable graph from the root, not just its direct children — CycloneDX's dependencies array exists precisely so a scanner does not have to guess how deep a name might be hiding.