Skip to content

Commit b473d72

Browse files
committed
Audit fix fallback for no-op updates for transitive deps
1 parent 94ed801 commit b473d72

18 files changed

Lines changed: 583 additions & 16 deletions

File tree

npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater.rb

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,21 +79,9 @@ def handle_pnpm_support_file_no_change!(updated_files)
7979
return unless original_pnpm_locks.any?
8080
return unless updated_files.none? || updated_files.all?(&:support_file?)
8181

82-
raise_tool_not_supported_for_pnpm_if_transitive
8382
raise_miss_configured_tooling_if_pnpm_subdirectory
8483
end
8584

86-
sig { void }
87-
def raise_tool_not_supported_for_pnpm_if_transitive
88-
return if dependencies.empty? || dependencies.any?(&:top_level?)
89-
90-
raise ToolFeatureNotSupported.new(
91-
tool_name: "pnpm",
92-
tool_type: "package_manager",
93-
feature: "updating transitive dependencies"
94-
)
95-
end
96-
9785
# rubocop:disable Metrics/PerceivedComplexity
9886
sig { void }
9987
def raise_miss_configured_tooling_if_pnpm_subdirectory

npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater.rb

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,42 @@ def run_npm_subdependency_updater(sub_dependencies:)
323323
def run_npm8_subdependency_updater(sub_dependencies:)
324324
dependency_names = sub_dependencies.map(&:name)
325325
NativeHelpers.run_npm8_subdependency_update_command(dependency_names)
326-
{ lockfile_basename => File.read(lockfile_basename) }
326+
327+
updated_content = File.read(lockfile_basename)
328+
if lockfile_content_unchanged?(updated_content, sub_dependencies)
329+
# `npm update` is a no-op for transitive dependencies not listed in
330+
# any package.json (common in workspace repos). Fall back to
331+
# `npm audit fix` which can update these in the lockfile.
332+
# npm audit fix exits non-zero when vulnerabilities remain, so we
333+
# rescue and use whatever lockfile changes it managed to make.
334+
begin
335+
NativeHelpers.run_npm_audit_fix_command
336+
rescue SharedHelpers::HelperSubprocessFailed
337+
Dependabot.logger.info("npm audit fix failed or partially fixed — continuing with any changes made")
338+
end
339+
updated_content = File.read(lockfile_basename)
340+
end
341+
342+
{ lockfile_basename => updated_content }
343+
end
344+
345+
sig do
346+
params(
347+
updated_content: String,
348+
sub_dependencies: T::Array[Dependabot::Dependency]
349+
).returns(T::Boolean)
350+
end
351+
def lockfile_content_unchanged?(updated_content, sub_dependencies)
352+
parsed = JSON.parse(updated_content)
353+
packages = parsed.fetch("packages", {})
354+
355+
sub_dependencies.any? do |dep|
356+
packages.any? do |path, details|
357+
next false unless path.end_with?("/#{dep.name}") || path == "node_modules/#{dep.name}"
358+
359+
details["version"] == dep.previous_version
360+
end
361+
end
327362
end
328363

329364
sig { params(dependency: Dependabot::Dependency).returns(T.nilable(String)) }

npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,19 @@ def run_yarn_berry_subdependency_updater(yarn_lock:)
255255
]
256256

257257
Helpers.run_yarn_commands(*commands)
258+
259+
updated_content = File.read(yarn_lock.name)
260+
if updated_content.include?("#{dep.name}@") &&
261+
updated_content.include?("version: #{dep.previous_version}")
262+
begin
263+
NativeHelpers.run_yarn_audit_fix_command
264+
rescue SharedHelpers::HelperSubprocessFailed
265+
Dependabot.logger.info(
266+
"yarn npm audit --fix failed or partially fixed — continuing with any changes made"
267+
)
268+
end
269+
end
270+
258271
{ yarn_lock.name => File.read(yarn_lock.name) }
259272
end
260273

npm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,37 @@ def self.run_npm8_subdependency_update_command(dependency_names)
4545

4646
Helpers.run_npm_command(command, fingerprint: fingerprint)
4747
end
48+
49+
sig { returns(String) }
50+
def self.run_npm_audit_fix_command
51+
# Fallback for transitive dependencies in workspace repos where
52+
# `npm update` is a no-op because the package isn't in package.json.
53+
# `npm audit fix` updates all fixable vulnerabilities in the lockfile.
54+
command = "audit fix --package-lock-only --ignore-scripts"
55+
fingerprint = "audit fix --package-lock-only --ignore-scripts"
56+
57+
Helpers.run_npm_command(command, fingerprint: fingerprint)
58+
end
59+
60+
sig { returns(String) }
61+
def self.run_pnpm_audit_fix_command
62+
# Fallback for transitive dependencies where `pnpm update` is a no-op.
63+
# `pnpm audit --fix` adds overrides to the manifest for vulnerable deps.
64+
Helpers.run_pnpm_command(
65+
"audit --fix",
66+
fingerprint: "audit --fix"
67+
)
68+
end
69+
70+
sig { returns(String) }
71+
def self.run_yarn_audit_fix_command
72+
# Fallback for transitive dependencies where `yarn up -R` is a no-op.
73+
# `yarn npm audit --fix` updates vulnerable deps in the lockfile.
74+
Helpers.run_yarn_command(
75+
"npm audit --fix --mode update-lockfile",
76+
fingerprint: "npm audit --fix --mode update-lockfile"
77+
)
78+
end
4879
end
4980
end
5081
end

npm_and_yarn/lib/dependabot/npm_and_yarn/update_checker/subdependency_version_resolver.rb

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,20 @@ def run_yarn_berry_updater(path, lockfile_name)
164164
"up -R #{dependency.name} #{Helpers.yarn_berry_args}".strip,
165165
fingerprint: "up -R <dependency_name> #{Helpers.yarn_berry_args}".strip
166166
)
167-
{ lockfile_name => File.read(lockfile_name) }
167+
168+
updated_content = File.read(lockfile_name)
169+
if yarn_update_was_noop?(updated_content)
170+
begin
171+
NativeHelpers.run_yarn_audit_fix_command
172+
rescue SharedHelpers::HelperSubprocessFailed
173+
Dependabot.logger.info(
174+
"yarn npm audit --fix failed or partially fixed — continuing with any changes made"
175+
)
176+
end
177+
updated_content = File.read(lockfile_name)
178+
end
179+
180+
{ lockfile_name => updated_content }
168181
end
169182
end
170183
end
@@ -177,7 +190,24 @@ def run_pnpm_updater(path, lockfile_name)
177190
"update #{dependency.name} --lockfile-only",
178191
fingerprint: "update <dependency_name> --lockfile-only"
179192
)
180-
{ lockfile_name => File.read(lockfile_name) }
193+
194+
updated_content = File.read(lockfile_name)
195+
if pnpm_update_was_noop?(updated_content)
196+
begin
197+
NativeHelpers.run_pnpm_audit_fix_command
198+
Helpers.run_pnpm_command(
199+
"install --lockfile-only",
200+
fingerprint: "install --lockfile-only"
201+
)
202+
rescue SharedHelpers::HelperSubprocessFailed
203+
Dependabot.logger.info(
204+
"pnpm audit --fix failed or partially fixed — continuing with any changes made"
205+
)
206+
end
207+
updated_content = File.read(lockfile_name)
208+
end
209+
210+
{ lockfile_name => updated_content }
181211
end
182212
end
183213
end
@@ -188,11 +218,51 @@ def run_npm_updater(path, lockfile_name)
188218
Dir.chdir(path) do
189219
NativeHelpers.run_npm8_subdependency_update_command([dependency.name])
190220

191-
{ lockfile_name => File.read(lockfile_name) }
221+
updated_content = File.read(lockfile_name)
222+
if npm_update_was_noop?(updated_content)
223+
# `npm update` is a no-op for transitive dependencies not in
224+
# any package.json (common in workspace repos). Fall back to
225+
# `npm audit fix` which can resolve these in the lockfile.
226+
# npm audit fix exits non-zero when vulnerabilities remain, so
227+
# we rescue and use whatever lockfile changes it managed to make.
228+
begin
229+
NativeHelpers.run_npm_audit_fix_command
230+
rescue SharedHelpers::HelperSubprocessFailed
231+
Dependabot.logger.info("npm audit fix failed or partially fixed — continuing with any changes made")
232+
end
233+
updated_content = File.read(lockfile_name)
234+
end
235+
236+
{ lockfile_name => updated_content }
192237
end
193238
end
194239
end
195240

241+
sig { params(updated_content: String).returns(T::Boolean) }
242+
def npm_update_was_noop?(updated_content)
243+
parsed = JSON.parse(updated_content)
244+
packages = parsed.fetch("packages", {})
245+
246+
packages.any? do |path, details|
247+
next false unless path.end_with?("/#{dependency.name}") ||
248+
path == "node_modules/#{dependency.name}"
249+
250+
details["version"] == dependency.version
251+
end
252+
end
253+
254+
sig { params(updated_content: String).returns(T::Boolean) }
255+
def yarn_update_was_noop?(updated_content)
256+
updated_content.include?("\"#{dependency.name}@") &&
257+
updated_content.include?("version: #{dependency.version}")
258+
end
259+
260+
sig { params(updated_content: String).returns(T::Boolean) }
261+
def pnpm_update_was_noop?(updated_content)
262+
updated_content.include?("'#{dependency.name}@#{dependency.version}'") ||
263+
updated_content.include?("/#{dependency.name}@#{dependency.version}")
264+
end
265+
196266
sig { params(path: String, lockfile_name: String).returns(T::Hash[String, String]) }
197267
def run_npm6_updater(path, lockfile_name)
198268
SharedHelpers.with_git_configured(credentials: credentials) do

npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater_spec.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,29 @@
384384
expect(updated_npm_lock_content).to eq(expected_updated_npm_lock_content)
385385
end
386386
end
387+
388+
context "when updating a subdependency in a workspace repo" do
389+
let(:files) { project_dependency_files("npm8/workspace_subdependency_update") }
390+
391+
let(:dependency_name) { "lodash" }
392+
let(:version) { "3.10.2" }
393+
let(:previous_version) { "3.10.1" }
394+
let(:requirements) { [] }
395+
let(:previous_requirements) { [] }
396+
397+
it "falls back to npm audit fix when npm update is a no-op" do
398+
# Simulate npm update being a no-op (transitive dep not in package.json)
399+
allow(Dependabot::NpmAndYarn::NativeHelpers)
400+
.to receive(:run_npm8_subdependency_update_command).and_return("")
401+
allow(Dependabot::NpmAndYarn::NativeHelpers)
402+
.to receive(:run_npm_audit_fix_command).and_return("")
403+
404+
expect(Dependabot::NpmAndYarn::NativeHelpers)
405+
.to receive(:run_npm_audit_fix_command).once
406+
407+
updated_npm_lock_content
408+
end
409+
end
387410
end
388411

389412
describe "npm errors" do

npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,33 @@
364364
.to include("https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3")
365365
end
366366
end
367+
368+
context "when updating a yarn berry sub-dependency and normal update is a no-op" do
369+
let(:files) { project_dependency_files("yarn_berry/workspace_subdependency_update") }
370+
371+
let(:dependency_name) { "lodash" }
372+
let(:version) { "3.10.2" }
373+
let(:previous_version) { "3.10.1" }
374+
let(:requirements) { [] }
375+
let(:previous_requirements) { [] }
376+
377+
before do
378+
# Stub run_yarn_commands to simulate a no-op (lockfile still has previous_version)
379+
allow(Dependabot::NpmAndYarn::Helpers).to receive(:run_yarn_commands)
380+
allow(Dependabot::NpmAndYarn::NativeHelpers)
381+
.to receive(:run_yarn_audit_fix_command).and_return("")
382+
end
383+
384+
it "falls back to yarn npm audit --fix when lockfile still has previous version" do
385+
expect(Dependabot::NpmAndYarn::NativeHelpers)
386+
.to receive(:run_yarn_audit_fix_command).once
387+
388+
Dir.mktmpdir do |tmp_dir|
389+
File.write(File.join(tmp_dir, yarn_lock.name), yarn_lock.content)
390+
Dir.chdir(tmp_dir) do
391+
updater.send(:run_yarn_berry_subdependency_updater, yarn_lock: yarn_lock)
392+
end
393+
end
394+
end
395+
end
367396
end

npm_and_yarn/spec/dependabot/npm_and_yarn/update_checker/subdependency_version_resolver_spec.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,32 @@
106106
it { is_expected.to eq(Gem::Version.new("5.7.4")) }
107107
end
108108

109+
context "with a yarn berry workspace subdependency" do
110+
let(:dependency_files) { project_dependency_files("yarn_berry/workspace_subdependency_update") }
111+
112+
let(:dependency) do
113+
Dependabot::Dependency.new(
114+
name: "lodash",
115+
version: "3.10.1",
116+
requirements: [],
117+
package_manager: "npm_and_yarn"
118+
)
119+
end
120+
let(:latest_allowable_version) { "3.10.2" }
121+
122+
it "falls back to yarn npm audit --fix when yarn up -R is a no-op" do
123+
# Stub yarn up -R to be a no-op (returns unchanged lockfile)
124+
allow(Dependabot::NpmAndYarn::Helpers).to receive(:run_yarn_command).and_return("")
125+
allow(Dependabot::NpmAndYarn::NativeHelpers)
126+
.to receive(:run_yarn_audit_fix_command).and_return("")
127+
128+
expect(Dependabot::NpmAndYarn::NativeHelpers)
129+
.to receive(:run_yarn_audit_fix_command).once
130+
131+
latest_resolvable_version
132+
end
133+
end
134+
109135
context "with a pnpm-lock.yaml" do
110136
let(:dependency_files) { project_dependency_files("pnpm/no_lockfile_change") }
111137

@@ -124,6 +150,32 @@
124150
it { is_expected.to eq(Gem::Version.new("5.7.4")) }
125151
end
126152

153+
context "with a pnpm workspace subdependency" do
154+
let(:dependency_files) { project_dependency_files("pnpm/workspace_subdependency_update") }
155+
156+
let(:dependency) do
157+
Dependabot::Dependency.new(
158+
name: "lodash",
159+
version: "3.10.1",
160+
requirements: [],
161+
package_manager: "npm_and_yarn"
162+
)
163+
end
164+
let(:latest_allowable_version) { "3.10.2" }
165+
166+
it "falls back to pnpm audit --fix when pnpm update is a no-op" do
167+
# Stub pnpm update to be a no-op (returns unchanged lockfile)
168+
allow(Dependabot::NpmAndYarn::Helpers).to receive(:run_pnpm_command).and_return("")
169+
allow(Dependabot::NpmAndYarn::NativeHelpers)
170+
.to receive(:run_pnpm_audit_fix_command).and_return("")
171+
172+
expect(Dependabot::NpmAndYarn::NativeHelpers)
173+
.to receive(:run_pnpm_audit_fix_command).once
174+
175+
latest_resolvable_version
176+
end
177+
end
178+
127179
context "with a npm8 package-lock.json" do
128180
let(:dependency_files) { project_dependency_files("npm8/subdependency_update") }
129181

0 commit comments

Comments
 (0)