Steering clear of the dependency trap

sumstrm

Andreas Sommarström

Posted on November 1, 2021

Steering clear of the dependency trap

With the dust settling after the UA-parser-js, coa and rc incidents, it's the perfect opportunity to take some time and see what we can learn from it. With some small changes to how you view and work with dependencies, you can take back control - instead of letting the dependencies control and overwhelm you.

The UA-parser security issue highlights two major things for the npm ecosystem:

  • The dependency tree comes with security risks. Your direct dependencies might not be malicious, but your dependencies of direct dependencies may be targeted. These transitive dependencies often range in hundreds and are large weak points.
  • Organizations need to expand the scope of security and protect more than the CI/CD. Developer environments are often more numerous and more difficult to control, making it a more probable target to be compromised by malicious packages.

In these ransomware times, it's more important than ever to protect your whole organization - controlling what packages are allowed in your environments.


The UA-parser-js incident in short JavaScript library ua-parser-js sparked vigorous security activity, as the package was hijacked and three malicious versions were published to the public npm registry. Once again highlighting the need for more security focus in the JavaScript (and other) ecosystems.

The library, used to detect browser and user data, has close to 8M weekly downloads by developers around the world and is used as a dependency by 1200+ other packages in the public npm registry.

See the security advisory for more details.

Update: Malicious versions of packages coa and rc published on 2021-11-04. Same malware and pattern of attack (and indicating same hijacker), targeting popular support libraries. Malicious versions of both packages later unpublished by npm.


Dependency tree and levels of dependencies

Joe, the junior developer, helps his team with application testing over the course of the weekend. His environment is clean, so he diligently installs the required dependencies for the application using npm install.

The app only has a few direct dependencies, but with all the transitive dependencies he ends up adding over 700 dependencies. Unfortunately for Joe and his company, it’s already too late as he unknowingly installed a trojan into his environment…

Installing npm dependencies with package managers is easy - and we shouldn't kid ourselves and think that everyone is knowledgeable and informed on potential issues. It's easy for things to start going wrong with the amount of dependencies and with packages allowed to execute arbitrary scripts as part of the install process.

And malicious packages are a big threat for your development environment where potential user data, passwords and sensitive information is stored and therefore can be stolen by hackers.

Getting compromised without even knowing it

Let's use the ua-parser-js attack as an example. During the incident any user installing a package with a dependency resolution like ua-parser-js: ^0.7.xx would have gotten a malicious version (0.7.29). The dependency resolution tells us to fetch the latest 0.7 patch version - unless remediated by factors like locked dependency versions.

So who was affected? Projects depending directly on ua-parser-js were obviously at risk. But in the typical case the compromised dependency was added as a transitive dependency (dependency of a dependency).
Leading to users working with popular libraries and frameworks like react and angular reporting inclusion of the compromised library.

With transitive dependencies, the number of packages your projects depend on increases dramatically. To the point where it quickly becomes impossible to fully grasp what and how many dependencies your team uses.

# Example of dependency tree with ua-parser-js included as a transitive dependency. 
# 'npm ls' is used to identify the path for a specific dependency

$ npm ls ua-parser-js
    yourproject@1.0.0
      react@15.7.0
       └─┬ fbjs@0.8.18
         └── ua-parser-js@0.7.30

# Excerpt from fbjs@0.8.18 where ua-parser-js is included as a dependency
    "dependencies": {
        ...
        "ua-parser-js": "^0.7.18"
        },

# ua-parser-js: ^0.7.18 would resolve to the latest 0.7.x version. Installing a compromised version with malware during the time of the incident.
Enter fullscreen mode Exit fullscreen mode

In the aftermath after ua-parser-js the biggest issue wasn't figuring out if your applications used ua-parser, instead it was trying to figure out if you were exposed - in any environment, in any way, across hundreds of developers. A task a lot of companies worked vigorously with, as they lacked proper control over packages that enter their environment.

How to avoid the trap? Control instead of being controlled

Avoiding similar issues in the future should be a priority - and any investment into proper protection would save time and money in the long run.

So, the million dollar question - How do we avoid this in the future?
We can mitigate most of the issues by inserting control over dependencies and the patch management process.

  • Avoid unintended dependency version changes
  • Use a single source of truth for dependencies

Locking dependency versions

You might be thinking, discussions about using lock-files again? Shouldn't everyone be aware of and use them by now? And I agree, everyone should use them - but they are not.

Dependency versions should be updated with intention and not as a side effect. Having consecutive npm install yield slightly different and non-deterministic results is not desired in neither CI/CD nor dev environments.

Organizations should put in place a process that updates, commits and reviews project lock-files and makes sure every subsequent installation (and user) uses the files.

Using dependency ranges, instead of pinning exact dependency versions, grants flexibility for the ecosystem - but comes with inherent security risks. Using lock-files (package-lock & yarn.lock) together with npm ci for complete and deterministic installations introduces the necessary friction that make updating dependency versions a controlled process.

Single source - The dependency firewall

Depending directly on public registries and countless GitHub repositories, instead of using a single package source, quickly makes control over the flow of dependencies an impossible task.

With multiple different sources, how are you going to make sure dependencies comply with your business policies, that they are safe and contain approved licenses?

The solution: a single hub like Bytesafe to enforce rules and monitor the flow of dependencies - for every developer, tester and build system.

To make sure everyone is using the same registry source and the intended versions, projects should include a .npmrc config file and package-lock.json or yarn.lock files that define what registry to use.

# Example .npmrc config setting the default registry to be used by npm clients
registry=https://workspace.bytesafe.dev/r/example-registry/
Enter fullscreen mode Exit fullscreen mode

Keep unwanted dependencies out of your organization. Setup a firewall for your dependencies with Bytesafe!

Thanks for reading!

💖 💪 🙅 🚩
sumstrm
Andreas Sommarström

Posted on November 1, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related