Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

Your package.json Says What You Asked For. Only the Lockfile Says What You Got.

Ninety-seven malware advisories were published to the GitHub Advisory Database yesterday: 94 npm packages and 3 PyPI. I pulled them with type=malware and read the names, and they split into two very different campaigns.

Ninety-seven malware advisories were published to the GitHub Advisory Database yesterday: 94 npm packages and 3 PyPI. I pulled them with type=malware and read the names, and they split into two very different campaigns.

Most were forks of one popular library republished under scopes nobody has heard of. Ten were something else: names shaped like somebody's internal packages rather than like public ones. @tesla-insurance/vinless-quote. @airbnb-extended/typescript-config. app-sca-info-banking. unified-platform. risk-detection.

That second group is dependency confusion, and the usual explanation of it stops one step before the part that decides whether you are exposed.

The explanation everybody repeats

An attacker publishes a package to the public registry using the name of one of your private, internal packages. Your install picks up theirs instead of yours.

True, and it leaves out the mechanism, which is the only part you can act on.

What actually decides it

npm's own documentation is plain about this. A scope can be bound to a registry, in .npmrc:

@myco:registry=https://reg.example.com

and then, in their words, "once a scope is associated with a registry, any npm install for a package with that scope will request packages from that registry instead." They also note that "scopes have a many-to-one relationship with registries: one registry can host multiple scopes, but a scope only ever points to one registry."

Without that line, there is no association to honour, and the request goes to the default registry like any other.

So exposure here is a configuration property, not a property of your dependencies. It is decided by a file most people have not opened this year, and it is the same answer for every package in that scope.

Which is why the reflex fix does not work

Ask a room what stops dependency confusion and somebody will say pin your versions. Exact versions, save-exact, no carets anywhere.

Pinning constrains which version. It says nothing about which registry answered. If the request is going to the public registry and an attacker has published your package name there, 1.2.3 matches their 1.2.3 exactly as happily as it would have matched yours. A pin is a defence against an upgrade you did not want, which is a different attack.

The lockfile is genuinely better, and still not the control you want. npm says it exists so that "subsequent installs are able to generate identical trees, regardless of intermediate dependency updates". Identical to what was resolved before. It protects what is already in it. A dependency added this afternoon resolves fresh, against whatever registry is configured now.

The field that answers the question

There is exactly one place in a normal project that records where a package came from, and it is not package.json. It is the resolved field in the lockfile, which npm documents as "the place where the package was actually resolved from. In the case of packages fetched from the registry, this will be a url to a tarball."

Which makes the audit a one-liner. Count the hosts your tree actually came from:

jq -r '.. | .resolved? // empty' package-lock.json \
  | awk -F/ '{print $3}' | sort | uniq -c | sort -rn

On a healthy private-registry project that prints your internal host and registry.npmjs.org, and nothing else. Anything third is worth ten minutes. And if a scope you believe is internal shows up under the public host, you have found the thing before somebody else did.

The part that generalises past npm

Look at what was true the whole time this was going wrong. The manifest was correct: the package name in it is the one you meant. The version was correct. Code review passed, because the diff showed a dependency you recognised. Every artefact a person would naturally read says the right thing.

The only record of what actually happened lives in a generated file that nobody opens, reviews, or reads in a pull request, and that most teams have at some point added to .gitignore by accident.

That shape is worth recognising, because it is not rare. When somebody tells you their build started failing or behaving strangely after an install, the question is never what was requested. It is what was fetched, at that moment, on that machine. Those are different claims, and only one of them is written down.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.