During the recent Axios Supply Chain Attack I have accidentally observed couple of devs checking whether they should be worried about their projects or not. I have noticed one interesting thing - the level of understanding of what's really going on, what is the nature of this kind of exploit and how to reliably protect yourself from future ones, is lower than it should be. Therefore, I decided to put together a few paragraphs that should help.
I am far from being a Node.js security guru, yet I believe I have learned a thing or two on my dev journey so far. If you insist on meeting the real expert, allow me to introduce you Liran Tal and namely his Awesome npm Security Best Practices repo with a huge list of dos and don'ts when dealing with NPM packages. For less scholarly version, you may proceed to my article.
What happened?
This type of cyberattack occurs when hackers gain credentials that allow them posting malicious versions of existing and legit packages. In this case, sophisticated social engineering was used to steal them directly from one of the devs. More common ways are accidental leaking (vibecoders, do you know what your AI coding assistant is committing into public Git repository?) or exfiltrating data through previous virus infections.
It usually doesn't take long before the malware is discovered by automated checkers, the maintainers are notified, and the infected packages are pulled off NPM. But during this time frame damage can be done.
The malicious code typically lurks in what is called a postinstall script. This is a code that is automatically executed once the package gets installed. While there are many legit use-cases for this, it might also become an open door to your system. And hackers know it (while many JS devs don't).
Why is it happening?
Despite it is quite easy to exploit npm's default behavior, it is also not that hard to build a good defense to prevent most of the common attack vectors.
So, let me first explain why it can happen and then I'll aim you with solid ways of fending this attack off.
NPM package resolution
One of the first commands you'd use when working with JavaScript is npm install. You probably know that it downloads dependencies listed in your package.json file into local node_modules directory. But the whole process is more complex.
When executed for the first time, it reads through package.json and resolves listed packages along with their versions. The version may be specified as an exact one, but you can also see caret (^) or tilde (~) ones. Those allow npm to use latest available version within the given range.
Exact vs loose versions
I'll assume you are familiar with semantic versioning or you will take a break now and find out. The important take is: It is effectively impossible to predict the exact outcome of a fresh npm install.
I mean, yes, you can pin down all your (direct) dependences, but:
- You will have harder time maintaining security updates in the future
- The first external library you pull in will probably contain some loose dependencies anyway and you won't be able to control all (unless you use overrides for each and single one - and get #1 squared)
I initially thought trying to pin down everything should be my goal, now I am far less convinced. Especially if you develop a library-like solution, you don't really want to make your future users dependent on you. Because if you strictly pin your dependencies, each time a vulnerability is uncovered (like couple of times per day), you would need to update your package.json and release new patch version of your library. With loosen dependencies, tools like npm audit --fix will be able to bump the required version directly in consumer's lockfile. Not only is this more convenient but it is also faster, because I reckon you are not always online to be of service.
Automated vulnerability
But this comes with a price. If a malicious version of a package exists at the time installation process runs and it is within the allowed range, it will be grabbed, downloaded and if it contains extra scripts, they will be executed.
It is hard if not impossible to reason with vanilla npm install. The flow is deterministic, but its scope is almost always too large as real projects contain many dependencies that are dependent on something else themselves. Would you take the time and control everything? Admit it - you just trust the system.
And the recent events (or the Shai-Hulud attack last year) showed us the blind trust may be dangerous.
What to do?
One would say JavaScript installation process is inherently unsafe and you better should just run away. But before you do, here are the three things you can easily do to mitigate most of the risks.
Disallow postinstall scripts by default
In cybersecurity awareness trainings you are taught not to click any suspicious links. Yet npm happily runs any suspicous script it gets. Unless you tell it not to do so.
The command is easy. Just use npm install --ignore-scripts instead of plain install. All of a sudden, no 3rd party scripts will be executed.
However, it is a double-edged sword, because there might be legit packages that rely on automated postinstall script and you'll end up in broken state unless you take further manual actions (like npm rebuild <package>).
Or, you can solve this in much more elegant way by switching to pnpm (which you should absolutely do for a number of other reasons as well). When pnpm runs install command, it never allows any scripts by default and only white-list those you allow via pnpm approve-builds manually or specify through onlyBuiltDependencies setting.
Either way, you should absolutely limit what is being executed and do not put yourself to the mercy of 3rd party package creators.
Commit your lockfiles
I remember having a conversation with one of my former colleague: "Oh, you don't commit package-lock.json into Git repository. It is big, it keeps changing and people keep creating conflicts by (accidentally) changing this file." I was taught differently back then and I insisted: My project will have the lockfile committed." After a few years, I am now pretty sure I was right.
The so called "lockfile" is created when the package manager runs install for the first time and gets updated when:
- some (direct) dependency was changed in
package.jsonsince last installation (the most common and expected way) - you run
npm install <package>ornpm uninstall <package>(effectively the same as #1) - you run
npm update(this re-resolves loosen caret/tilde deps) ornpm audit --fix(this auto-patches known vulnerable packages, if prescribed version ranges allow it) - the package manager version is different (the unwanted source of confusion and conflicts around the dev teams)
Otherwise, you will end up with guaranteed "bill of materials", that doesn't change between builds. Not only this helps to reduce infamous "it works on my machine" bugs, but it also fully prevents sudden changes in the supply chain.
If you don't include lockfile in your repository, everytime the repo is pulled (manually or inside an automated pipeline), new fresh npm install runs and up-to-date dependencies are re-resolved. The final build will be almost always different from previous one.
To be even safer (especially in pipelines), you can switch from using npm install to npm ci (aka "continuous integration") - a command that works mostly the same but completely respects existing lockfile and doesn't ever try to change.
Install with cooldown
The above is good, but if you're unlucky enough, you will create/update lockfile exactly during the ongoing supply chain crisis.
Fortunately, such attacks are usually short-lived. In the latest Axios case, it only took like 6 minutes before the compromised package version was discovered and totally 3 and half hours to take it off. It is very unlikely some malicious package can survive longer than let's say a week. If we can establish a rule that every package version we install must be at least a week old, we would easily prevent 99.9% of such incidents.
Good news everyone! It is possible with npm. However, there are buts.
If you already updated to newest v11, you were gifted with minimum-release-age flag. This allows you to specify the minimal number of days that had to pass between package release date and today. Newer versions will be rejected (silently if there is older fallback allowed by the version constraints and with failure if some exact version is requested).
Versions v10 and earlier had to rely on slightly less ideal before where YYYY-MM-DD date string is passed. This might get tricky to allow rolling dates and you had to do some scripting magic to achieve it.
Again, this will protect you from newly published malware, but it will also cut you off from desired updates (like urgent security patches).
And again, I cannot but advise you to switch to pnpm, because it naturally combines minimumReleaseAge with minimumReleaseAgeExclude to solve exactly this problem.
Summary
I am not promising following the advice above will always keep you safe. The topic of Node.js security is complex, and hackers are creative. But many exploits occur only because someone was unaware/lazy/hasty and left a plain npm install without taking at least minimum precaution.
Yes. If this is new for you, you may find it confusing and overwhelming. The working pipeline suddenly broke. You eagerly tried to use pnpm but the container image you use for builds is not knowing it (RUN npm install -g pnpm to the rescue). You imposed cooldowns and installation log exploded with errors. You banished post-install scripts and now your app won't start. But all the issues can be solved, and you will be rewarded with better sleep knowing you don't have to freak out every time new cyberattack is announced.
If you have questions and remarks, feel free to share your thoughts in the comments section below. Maybe you know something that I don't and I will be more than happy to find out 👀
Top comments (0)