/pkgsec

Default protection against malicious lifecycle scripts of project dependencies.

Primary LanguageTypeScript

PkgSec

Package Secure

  • tl;dr, Defaults to preventing all dependencies (including all transitives) from running lifecycle scripts without being explicitly whitelisted (via. your package.json)

All npm packages may ship with lifecycle hooks that execute arbitrary code (without constraint) on the package consumers machine.

Transitives dependencies (dependencies of dependencies) will also run their lifecycle hooks when adding/installing packages for a project, meaning that one compromised indirect dependency could compromise a package

An example is with React projects that use create-react-app (cra) which abstracts away project scripts via the react-scripts package. this package in itself has heaps of dependencies like @babel/core, camelcase, webpack, dotenv etc, and they likely have dozens more dependencies under them. All it takes is one dependency upon hundreds, as installed into a project via npm install to pwn your machine with malicious code -- a supply chain attack.

There are some strategies to mitigate the risk, the easiest being running npm install or yarn install with the --ignore-scripts flag. This will prevent the execution of package lifecycle hooks completely, thus preventing a potentially compromised package or sub-package from executing malicious code on your machine.

However, one main drawback is that this flag ignores everything INCLUDING your project's own scripts in package.json, and there are no options to specify a most precise target to ignore (or allow) (feature request). And even if you don't have any lifecycle scripts in your own project, many projects use packages such as esbuild which will fail if you ignore scripts -- so you'll end up omitting this --ignore-scripts flag just so you can get back to work, however the problem still exists and the risk still at large.

And even if they did implement a way to narrow the ignore-scripts flag's scope this feature request is defaulting deny-list approach, where everything is allowed by default and you specify what packages to not run scripts, whereas it would be more secure to operate with an allow-list, so that you only run scripts of packages that you trust as specified to the install command.

Unfortunately, although this would be an effective way to stop malicious code executing from compromised packages that previously had no lifecycle scripts, this still means that hijacked trusted-packages are still allowed to execute new malicious code. Some strategies:

  • along with specifying trusted packages, also explicitly specify trusted lifecycle hooks
  • along with trusted packages by name, also include a hash of the lifecycle script's content (to defend against inline malicious code execution), and a list of dependencies (such as the target .js/.sh script files) to hash for comparison (and the actual hashes of the target files)

These strategies are proposed in contrast to simply pinning a single "trusted" version, because it is inevitable that you will need to update a package's version thus you want these protections to be as unobtrusive to the user as to avoid a user blindly bumping the trusted version pointer to the newly installed version, thus bypassing the life-cycle script protection altogether.

These trusted properties can reside alongside the dependencies as another package.json property such that it must be version controlled.

This solution would probably be best injected as a kind of hook that catches all attempted executions of lifecycle hooks as part of an npm or yarn install, such that all attempted executions that are not trusted will raise an exception and prompt the user about the potentially suspicious behaviour. This would play out as the user doing an innocent yarn upgrade or upgrade-interactive (or equivalent in npm) and then noting any NEW exceptions that may indicate a newly compromised package -- or that the build scripts have been changed by the developer and should be noted in the documentation.

This experience can be improved through an interactive flow that guides the user to inspect the differences between the files (due to a mismatch of hashes) to check that nothing suspicious has been added (to indicate compromise), and if the user is satisfied, then they may commit the new hash to the "integrity" property within package.json and continue inspecting conflicting packages until they are satisfied with their audit.

This would have the bi-product of encouraging developers to be very conservative with updates to their lifecycle scripts, but this seems to be a small price to pay considering that lifecycle scripts are significantly less volatile than actual application code.

To be clear, this is a way to mitigate against supply chain attacks that execute upon installation, almost like a 0-day -- placing malicious code into the application code itself will still be a problem, however this intends to defend against the huge lifecycle-scripts attack vector, whereby a user may be pwned just by installing a project's dependency, let along by running the application itself.