The worst fear of a JavaScript developer

Published by Farrukh Jadoon (@fkj) and Umar Sikander (@us)
primo secondo
By @fkj  and  @us

A discussion on the risks of transitive dependencies in JavaScript.

One of the most common tasks of a JavaScript developer is installing a package, also known as a “dependency.” As one of the most popular programming languages in the world, as shown by the TIOBE and PYPL index, JavaScript has an enormous ecosystem around its packages.

The big bang of npm

A big part of why developers love using JavaScript is its rich toolchain. Ranging from type helpers like TypeScript, to frontend frameworks such as React and module bundlers like Webpack, to everything in between – it’s safe to say that almost every JS developer has used a package created by someone else, one way or another.

This ease of reuse is made possible through npm – the package manager and registry sitting at the heart of the JavaScript ecosystem (akin to pip in Python or RubyGems in Ruby). Since its creation by Issac Schlueter in 2010, npm has become the most popular package repository in the software world today. For reference, npm indexes over 2 million unique packages—over four times as many as the next most popular, maven. This is a clear testament to how much JavaScript developers rely on packages created by other developers.

Growth of npm
Growth of npm

However, the very ecosystem that makes npm so great is also posing the biggest question to its existence today. Liberal design around the creation and consumption of packages has resulted in an increasingly large surface area spread over millions of machines, modules, and their authors - which has become increasingly difficult to audit, observe, and secure.

90% of a modern web app comes from npm - npm

Given this kind of super-dependence the issues we will discuss in this post impact not just developers, but anyone and anything that touches the web today.

Enter package.json

To understand this further, we’ll dig deeper into the internals of npm. Every JavaScript project using npm has a package.json file. This manifest contains a variety of information, such as the name of the application, the version, and the dependencies. For example, if you want to use the package @solana/web3.js as a dependency in your project, you add the following line in your package.json file:

...
 "dependencies": {
    "@solana/web3.js": "^1.50.1"
  }
...

It’s also possible to define other types of dependencies in your package.json file:

  • devDependencies - these are the dependencies used during the development process, for example, a prettier library for formatting code
  • peerDependencies - used to specify that our package is compatible with a particular version of another npm package it depends on
  • optionalDependencies - these dependencies are optional and failing to install them will not break the installation process
  • bundledDependencies - an array of packages that are bundled with your project and are useful when some 3rd-party library is not in the public registry, or when you want to include internal modules to your project

Transitive dependencies in npm

We’ve discussed in detail the problem of super-dependence and where transitive dependencies come in.

This is one of the main attractions of the JavaScript ecosystem: the fact that you can add functionality so easily to your projects, leaving less coding for you. As humans (and especially developers) tend to follow the path of least resistance, this is an incredibly attractive value proposition. It is also the reason that most developers add packages without any due diligence.

A recent study states that installing an average npm package introduces an implicit trust in 79 third-party packages and 39 maintainers

The problem, and what you need to be cautious about, is that of implicit trust. When you import a package, you are not only implicitly trusting the people and contents of it, but all of the packages that it’s dependent on (aka transitive dependencies).

Sam Bleckley’s research on npm found that it’s common for packages to have dependency trees that go 2, 3, 4, or even 5 levels deep (with a sizable number of packages with a dependency tree of more than twenty.)

Graph showing the distribution of dependency trees
The distribution of dependency trees

Case study: solana web3.js

To illustrate how this works, lets look at the case of solana/web3.js npm package. A simple npm install @solana/web3.js installs 722 modules, and that’s just from a single dependency. So, while you may be doing your due diligence on all the packages you install as dependencies, you are trusting that the maintainers of @solana/web3.js are doing so as well. To make things worse, you’re not only putting your trust into the maintainers of @solana/web3.js, but you’re also putting your trust into the transitive dependencies of that project.

This was explained in a tweet raising the concern:

At this point, performing due diligence on every single package that you add to your application becomes a very time-consuming and costly process. Below, you can see an excerpt of the dependency graph for @solana/web3.js:

Dependency graph for @solana/web3.js
Dependency graph for @solana/web3.js module
  • Transition to how keeping track of dependencies is not possible: Today, its humanly impossible to audit and track all the transitive deps and changes. Furthermore - the behavior that it exhibits downstream, in the context of your developer environments (such as local machines, GitHub and CI/CD systems).
  • And what problems it can result in

Bringing in dependencies without knowing what they will do can result in many blind spots…

How bad can it get? The case of colors

In January 2022, the very popular package colors became a great example of how transitive dependencies can pose a great danger to the entire software community. colors is a simple package with simple functionality: it allows you to add colors to your terminal output.

Being such a useful utility package, it ended up being used by a lot of popular projects, such as AWS’s aws-cdk module. In fact, the package received (and still receives) over 20 million downloads per week.

The incident did not result from actions of a malicious adversary, but from the actions of the legitmate maintainer himself. Essentially, he became tired of maintaining a project for free and of Fortune 500s making a profit from his free labor. Due to this burnout and failure to seeking funding for such an essential package in the JavaScript ecosystem, he decided to push the following commit to his module on the npm registry:

for (let i = 666; i < Infinity; i++;) {
   if (i % 333) {
       // console.log('testing'.zalgo.rainbow)
   }
   console.log('testing testing testing testing testing testing testing'.zalgo)
}
The content of the malicious commit on the colors npm package

This seemingly innocent code snippet introduced an infinite loop when executed, and any application using this version would be subject to a Denial of Service (DoS) vulnerability. The lesson to drive home here is to be cautious about what dependencies you are adding to your application, and the trust you’re putting in them. A simple package that adds colors to terminal output ended up causing thousands of applications to be broken. Within 24 hours, the new version was already downloaded over 100,000 times.

Conclusion

After reading this post, you might be tempted to avoid all third-party modules entirely. Of course, this is not a practical approach as the appeal of using JavaScript comes from both the large ecosystem of modules on npm and the velocity it unlocks for building production-ready applications without the costly endeavor of implementing things from scratch.

The goal here is to stir awareness and spark a discussion of how the JavaScript dependency model can potentially introduce unknown threat vectors to your environments and applications, through handing over trust to entities you may not be aware of.

There is no silver bullet to avoid issues like these, but a good start is to start thinking about the problem. Make sure you perform as much due diligence as possible on your dependencies and incorporate proactive security controls, tools and practices into your development process as early as possible.