Don't `npm install` your way to success: a different way of thinking about dependencies

Ryan Block’s avatar

by Ryan Block
@ryan
on

packages Photo by Claudio Schwarz on Unsplash

It is sometimes said that your greatest strength is also your greatest weakness, and it is certainly so that JavaScript’s greatest strength is its incomprehensibly massive developer ecosystem. There’s a JS module for just about everything imaginable, and thousands of new modules and module versions are published daily.

The intention of this post is to demonstrate a different approach to adopting and using dependencies. And while we’ll largely refer to JavaScript, these principles likely apply to numerous other ecosystems as well.

Why we use dependencies

As developers, it’s easy to see why we rely on so many battle-tested dependencies. After all, doing so seems to conform with many key principles of software development:

  • Keep it simple – what could be simpler than using a reliable, popular dependency?
  • Given enough eyeballs, all bugs are shallow – isn’t it wise to trust code that’s been seen by thousands of developers?
  • Don’t repeat yourself - why write code when someone else already wrote and proved it?
  • Test your code – isn’t it killing two birds with one stone to get functionality – and the safety of its tests – for free?
  • Outsource the undifferentiated – isn’t it better to focus more time solving your customer’s problems?

Adopting dependencies has become a way of life for most of us in the JS ecosystem. At Begin, we often refer to it as npm install-ing your way to success.

What’s wrong with this picture

The unfortunate reality that’s often forgotten when typing in npm i is that dependencies only create the illusion of trustworthiness, safety, and efficiency. Unless you personally audit the code in your dependencies – and the code in your dependencies’ dependencies, and so on – you are effectively outsourcing the safety and security of your application to “enough eyeballs”, e.g. “the wisdom of the crowds”.

This is also known as bandwagon fallacy, or the appeal to popularity, and you should be on guard when you see it in action. The number of developers who’ve adopted a dependency has very little to do with that module’s safety and security. In fact, it’s inverse: the more popular the package, the more useful it is as a vector for delivering malicious payload.

All code is a liability

As developers, we love harnessing the power of code. Finding ways to make the lump of rock we tricked into thinking do precisely what we imagine is a surefire endorphin hit every single time. But it’s also important to remember that all code is a liability, and the more distant you are from the code being executed in your app – as is the case in sub-sub-sub-dependencies – the greater the liability of that code.

I refer to this concept as total code liability. The thinking goes something like this:

  • All code is a liability
  • Code that you and your team own and control has the least liability; the circle of trust knows what’s changed, why it’s changed, and can control its release
  • Foreign code - code that you and your team do not fully audit and that you do not control – is an even greater liability; you may know something about what’s changed, but likely not much, and you cannot control its release
  • Foreign code that relies on more foreign code (that relies on more foreign code, and so on) is the greatest liability; you likely know nothing about what’s changed, and you cannot control its release

Fortunately, most developers already view code as a liability (albeit not necessarily in those terms). Many of the most common software development principles, like KISS, DRY, and YAGNI, are fellow travelers with the general belief that code is a liability.

At Begin we routinely see folks with node_modules directories with hundreds of MB of dependencies – literally millions, if not tens of millions of lines of code. Why are we so quick to trust our app’s quality, security, and speed to millions of lines of code from unknown authors coming in as dependencies of dependencies?

If you’re not a little scared by this idea, you should be. Last week a maintainer of node-ipc released a protestware version of the package to corrupt files on target users hard drives. leftpad, ua-parser-js, coa, rc, colors, faker, and the dozens of other hacked, vandalized, or malicious packages that have made their way into production software are only the beginning of the supply chain attacks to come. Tools like Snyk may help us mitigate such problems, but they are also purely reactive, meaning responses come after an incident may have occurred. What if we do the work to minimize the chances of an incident ever happening to begin with?

Breaking the addiction

The message here is not to stop relying on dependencies. Instead, I propose that as authors and maintainers of code – and especially as consumers of dependencies – we must instead endeavor to reduce our total code liability. We should be discerning with every single npm i, and that process begins with acknowledging that:

  • It is ok to not write code – the surface area of many problems can be reduced, even eliminated, by reducing features, being thoughtful about implementation, or simply saying no (see: YAGNI)
  • It is ok to rely on language and runtime builtins - always prefer equivalent or near-equivalent builtins over popular dependencies
  • It is ok to write your own code - instead of relying on someone else’s code
  • It is ok to proactively audit your dependencies – if do you want to use someone else’s code, thoroughly audit it before committing to its use
  • It is ok to create a new module - if you can’t (or don’t want to) thoroughly audit someone else’s code, yet also don’t completely trust it as-is, it’s ok to write your own simpler, pared down implementation, even if it duplicates someone else’s functionality

All this be made actional by following two simple steps the next time you find yourself typing npm i:

1. Actively try not to npm i

There will be a moment where you’re working through a problem and arrive at a task that is perhaps best suited to [your favorite dependency for solving that task]. This is the moment to consider the total code liability of your application.

I suggest slowing down for a moment, assessing the problem, and asking the following questions:

  • Am I certain there doesn’t already exist a builtin for accomplishing this task?
  • If not, can this be (relatively) easily/reliably accomplished without installing an external dependency?
  • If not, have I recently searched for alternative dependencies that are smaller, simpler, and have zero dependencies of their own?

This is only slightly more work than npm i, and applied throughout the entire dependency tree, you’ll soon notice that simply taking these factors into consideration often results in a completely dramatic reduction in external dependencies.

Example

uuid is a great package for creating random UUID’s but as of Node.js v14.17.0 it’s unnecessary as the built-in crypto module contains a randomUUID method which generates a random RFC 4122 version 4 UUID, the same as the uuid package.

2. When you need to install a dependency, apply a bit of diligence

This one seems painfully obvious to say out loud, but it’s super important and bears noting: don’t just pick the popular dependency for a task. In fact, bias against the popular dependencies, as popularity usually comes with feature requests (read: bloat, which increases your total code liability), and said popularity makes it a juicier target for a supply chain attack.

If you need to install an external dependency, ask the following questions:

  • Who maintains this dependency? What are their standards for accepting patches? What are their stated security practices for ensuring the safety of their dependency?
  • How many sub-dependencies does this dependency have?
    • The ideal answer is zero.
    • If the answer is non-zero, research those sub-dependencies as well.
  • How large is this dependency?
    • Code is a liability after all, but more code is also inefficient
    • The only way to truly test this is to install this dependency – and only this dependency – in its own folder; for more about this, see below

In my experience, with a minimal amount of digging, there’s almost always a smaller, leaner, zero-dependency module out there waiting to be found.

Example

Both dompurify and xss are excellent packages for sanitizing input but dompurify and it’s dependencies weigh in at 10 Mb while xss and its dependencies are only 241 kb. For a deeper dive read our post on Sanitizing Input.

Wrap-up

I hope we’ve inspired you to reconsider the practice of npm install-ing your way to success, to be a bit more skeptical of adding dependencies – especially the popular ones – and to consider the concept of code as a liability. Put into practice, we believe this approach can help you to create safer, more secure, more efficient software without sacrificing developer experience or developer velocity.


Bonus: inspect modules in isolation

Here’s a fun, quick hack to look at any npm module in complete isolation, and to gauge its size. Add this to your .bash_[profile|rc]:


npmtest () { mkdir ~/YOUR_MODULE_TEST_PATH && mkdir ~/YOUR_MODULE_TEST_PATH/$1-test/ && cd ~/YOUR_MODULE_TEST_PATH/$1-test/ && echo {} > package.json && npm install $1 && du -sh node_modules/*; }

Then run: npmtest gatsby