Dependencies: other people’s code that yours runs
In short: Dependencies are other people’s code that your program runs; the lock file and the package hash guard against tampering, and a scanner finds known vulnerabilities.
Problem
The package nobody chose
The shop’s package.json lists 14 packages. node_modules has 900. Each of the 14 brings its own dependencies, and those bring theirs.
One night somebody gets into the account of the author of a small date formatting package. The package is deep in the tree: the shop uses it through the PDF invoice library, and nobody in the team knows it is there. The attacker uploads a new version with one extra line: a line that reads the process environment and sends PAYMENT_API_KEY out. In the morning the shop’s build pulls the new version, because the range ^2.1.3 allows it.
The key that you moved out of the code and into the environment in the previous lesson leaks from there. The shop got nothing wrong in its own code.
This is not made up. It happened with event-stream (2018) and with ua-parser-js (2021), and in 2024 the swap in xz utils was found shortly before it reached servers around the world.
The idea
Your process, someone else’s code
Every package the program loads runs in your process and with your rights. It reads the same environment and the same files, and talks to the same network. Some packages run code already at install time (npm’s postinstall scripts, setup.py in old Python packages).
The packages you asked for are direct dependencies. The ones they bring are transitive. The transitive ones are usually the majority, and nobody chose them.
This is the supply chain: from the package author through the registry (npm, Maven Central, NuGet, PyPI) to your build. An attacker can get in at any link:
- a hijacked account of an author: a new version with harmful code;
- typosquatting: a package named like a known one, but one letter off (
reqeusts,lodahs), waiting for a typo; - a swap on the way: a mirror, cache or proxy that hands over different bytes under the same name and version;
- an abandoned package that someone takes over and "updates" months later.
Against all of this there are two simple rules. Know exactly which versions get in. And check that the bytes are the same ones you checked.
See it
The tree and the swapped package
static bool Verify(Tarball pkg, Dictionary<string, string> lockFile){ var expected = lockFile[pkg.Name]; var actual = Integrity(pkg.Bytes); if (actual != expected) { Console.WriteLine($"{pkg.Name}: TAMPERED"); Console.WriteLine($" lock: {Brief(expected)}"); Console.WriteLine($" fetched: {Brief(actual)}"); return false; } Console.WriteLine($"{pkg.Name}: OK"); return true;}Step by step, in words
- The shop asks for two packages. They bring three more of their own. All five run inside the shop.
- Somebody swaps date-fmt on the way. Without a check its code runs in the shop, with its environment and files.
- The lock file holds the exact version and hash of every package, the deep ones too.
- The hash of what was downloaded: SHA-512, written in Base64.
- The same hash as in the lock file: these are the bytes that were checked. Installed.
- date-fmt has one extra line, and the whole hash is different. It is not installed.
- Installed: 4 of 5. The build stops, and nobody runs the swapped code.
The hash here is a relative of GetHashCode from the lesson on hash codes, but it is cryptographic. SHA-512 gives 64 bytes, and nobody can make another package with the same fingerprint. The hash code in collections is 4 bytes and repeats easily: it is fine for buckets in a table, but not for checking someone else’s code.
The idea
The lock file: the exact version and its fingerprint
The manifest says what you want: "pdf-invoice": "^5.0.0" means "5.0.0 or a newer 5.x". The lock file says exactly what is installed: the version of every package in the tree, the transitive ones too, and a hash of its contents. It is kept in git, and the build installs exactly that.
In .NET the lock file is packages.lock.json. You switch it on in the project:
<PropertyGroup> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile></PropertyGroup>Every entry has resolved (the exact version) and contentHash (the SHA-512 of the package). CI runs dotnet restore --locked-mode: it stops if it would have to change the lock file.
The check is simple: a hash of the downloaded bytes and a comparison with the entry. Here it is as a program, with a hash in npm’s form, sha512- and Base64:
static string Integrity(byte[] bytes) => "sha512-" + Convert.ToBase64String(SHA512.HashData(bytes));
static string Brief(string hash) => hash[..19] + "…";static bool Verify(Tarball pkg, Dictionary<string, string> lockFile){ var expected = lockFile[pkg.Name]; var actual = Integrity(pkg.Bytes); if (actual != expected) { Console.WriteLine($"{pkg.Name}: TAMPERED"); Console.WriteLine($" lock: {Brief(expected)}"); Console.WriteLine($" fetched: {Brief(actual)}"); return false; } Console.WriteLine($"{pkg.Name}: OK"); return true;}var lockFile = new Dictionary<string, string>{ ["pdf-invoice@5.0.2"] = "sha512-IN0lH/YKriHbj87FMea7idgW4qdE8vgdeYTdEVVqz/GwAnMAzmU8X7TOO2TJ20CSQLaDtD9hKB7J5bw8JLos0A==", ["font-kit@3.1.0"] = "sha512-19+g7wktnKQh+JGfMRNeNdrG3//S+BsOUeuBcAp8p10kuHCpM3HFgbQzKnzTQ+DRLcA2b1MmnHFfHYvjFrjm9g==", ["date-fmt@2.1.3"] = "sha512-Jeb+bpiC7IqY/wWOY2a0umsIM6ce0XcMc8XlRr9udQktm8kZCNIV+qcJtad0Uip8hhhdn9P+8yE6p56qsZkZSw==", ["pay-sdk@4.2.0"] = "sha512-qyQmG1zNAk4HmkwNd+yxZ0GkyOsQoLn9CGHMW2LsBWmkhwpMo7rXHYT2keUIu6n50HuYBjv411Twk69Wqc0nig==", ["http-lite@1.8.0"] = "sha512-TI5SSzlpujK57oie/hoakeRXqVh5YuW22qwmU7I6moo75Gl3qXY5ZqRuEsy0sKdy6dz7uqhlJzDDliKF4tUQQA==",};Tarball[] downloaded =[ new("pdf-invoice@5.0.2", Encoding.UTF8.GetBytes("pdf-invoice 5.0.2: renders invoices to PDF")), new("font-kit@3.1.0", Encoding.UTF8.GetBytes("font-kit 3.1.0: loads and measures fonts")), new("date-fmt@2.1.3", Encoding.UTF8.GetBytes("date-fmt 2.1.3: formats dates and times\n+ one line added by the attacker")), new("pay-sdk@4.2.0", Encoding.UTF8.GetBytes("pay-sdk 4.2.0: talks to the payment provider")), new("http-lite@1.8.0", Encoding.UTF8.GetBytes("http-lite 1.8.0: a small HTTP client")),];int installed = downloaded.Count(pkg => Verify(pkg, lockFile));Console.WriteLine($"Installed: {installed} of {downloaded.Length}");pdf-invoice@5.0.2: OKfont-kit@3.1.0: OKdate-fmt@2.1.3: TAMPERED lock: sha512-Jeb+bpiC7IqY… fetched: sha512-sMR370edweyw…pay-sdk@4.2.0: OKhttp-lite@1.8.0: OKInstalled: 4 of 5The packages here are a short text, but for the hash there is no difference: they are bytes. One extra line in date-fmt and the hash is completely different, from the very first character.
Try it
Predict the output
The two texts differ only by one space at the end. What will it print?
var a = Integrity(Encoding.UTF8.GetBytes("total 49.90"));var b = Integrity(Encoding.UTF8.GetBytes("total 49.90 "));Console.WriteLine(a.Length);Console.WriteLine(b.Length);Console.WriteLine(a == b);SHA-512 always gives 64 bytes, and Base64 writes them as 88 characters; with sha512- that makes 95, however long the input is. One extra space changes the whole hash, so the comparison is false.
Try it
Fill in
Which algorithm gives the hash in the lock file?
static string Integrity(byte[] bytes) =>
"sha512-" + Convert.ToBase64String(.HashData(bytes));SHA-512: that is the algorithm in npm’s sha512- and in NuGet’s contentHash. Gradle and pip use SHA-256, which is cryptographic too.
Try it
Find the bug
A colleague writes the check again. The registry server returns the package together with its hash, so the colleague compares with that. Which line is the bug?
Pick the line with the bug.
The expected hash comes from the same place as the package. Whoever can swap the package can swap the hash next to it, and the check always passes. The expected value must come from the lock file: it is in your git and was written when the package was checked. lockFile is not even used.
The idea
Known vulnerabilities and updating
The lock file guards against swaps, but it also freezes the old bugs. A package without harmful code can have a vulnerability: a bug that lets an attacker in. An example is Log4Shell (2021): one line in the log of a server with log4j-core before 2.17.1 was enough for the attacker to run their own code.
Vulnerabilities are published in databases (CVE, GitHub Advisory Database, OSV), and tools compare your tree with them:
- JavaScript:
npm audit; - Python:
pip-audit; - .NET:
dotnet list package --vulnerable --include-transitive; - Java: OWASP Dependency-Check (a plugin for Maven and Gradle);
- for all of them: Dependabot and Renovate open a pull request with the update.
The hygiene of updating:
- Small and often. Updating one version with tests behind it is safe. Five years at once is not. This is where tests as a safety net help.
- Critical ones at once, especially when the fix is already public.
- Read what changes before you accept a major version.
- Remove what you do not need. Every package is code you run. Six lines of your own code are sometimes cheaper than a package with 40 dependencies.
- A new dependency is a decision. Who maintains it, how many people, how many dependencies it brings, when the last version came out.
More precisely, if you are curious
Ranges. In npm ^2.1.3 allows any 2.x.y from 2.1.3 up, ~2.1.3 only 2.1.y. That is why without a lock file two builds of the same code give different trees. Libraries write ranges so they do not force everyone onto one version; applications lock the exact tree.
Install scripts. npm ci --ignore-scripts does not run postinstall and the like. Many projects do this by default in CI and allow scripts only for listed packages.
Your own registry. Companies often pull packages through their own proxy registry (Artifactory, Nexus, Azure Artifacts). It keeps a copy, lets only approved packages through and tracks what is used where.
SBOM. A Software Bill of Materials is a list of every component in the product, in the CycloneDX or SPDX format. When the next Log4Shell comes out, the answer to "are we affected?" is a search in it, not a week of digging.
Signatures. A hash says "the same bytes as when they were checked". A signature says "published by this author": npm provenance, Sigstore, signed packages in NuGet and Maven Central.
How to see the tree: npm ls --all, mvn dependency:tree, gradle dependencies, dotnet list package --include-transitive, pipdeptree.
Exercise
A small audit
Solve it in your own editor.
Installed are log-kit 2.14.1, http-lite 1.8.0, pay-sdk 4.2.0 and qr-code 2.9.0. The vulnerability database says (package, fixed in version, severity): log-kit 2.17.1 critical, http-lite 1.8.2 high, pay-sdk 3.9.0 moderate, qr-code 2.10.0 low. Print for each one whether it is vulnerable and which version to update to, and how many are vulnerable:
log-kit 2.14.1: vulnerable (critical), update to 2.17.1http-lite 1.8.0: vulnerable (high), update to 1.8.2pay-sdk 4.2.0: fineqr-code 2.9.0: vulnerable (low), update to 2.10.0Vulnerable: 3Careful with qr-code: as text, "2.9.0" comes after "2.10.0", because 9 comes after 1. Versions are compared part by part, as numbers. In .NET Version.Parse does it for you.
More tasks
- Severity. Sort the vulnerable ones by severity: critical, high, moderate, low.
- A lock from text. Read the lock file as lines
name@version hashand use it to check the downloaded packages from the lesson. - Typosquatting. Compare the name of a new package with a list of popular ones and warn if it differs by one letter (one change, insertion or deletion).
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: record, switch and patterns · Tests as a safety net · Untrusted input · Secrets in code and in git
- Tasks on this topic: Security for developers