Skip to content

Commit 51f0bdc

Browse files
robaikenCopilotCopilotCopilot
authored
Audit fix fallback (#14589)
* Audit fix fallback for no-op updates for transitive deps * receive -> receive_messages * Update npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater_spec.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor lockfile update checks to compare original and updated content for npm and yarn * Track audit fix usage in dependencies for improved PR naming * added feature flag * Better support for pnpm * Add tests for audit fix fallback behavior in subdependency version resolver * fixing tests * Add --force flag to npm audit fix and fix yarn updater to reuse updated_content Agent-Logs-Url: https://github.com/dependabot/dependabot-core/sessions/988c3f95-a729-413c-98ac-994924de2c00 Co-authored-by: robaiken <6567647+robaiken@users.noreply.github.com> * make sure we are returning version for berry workspaces * Revert pnpm audit fix if it modifies package.json pnpm audit --fix adds overrides to package.json. Since run_pnpm_update and run_pnpm_updater only return lockfile content, a manifest change would produce inconsistent output. Snapshot package.json files before the fallback and revert both manifest(s) and lockfile if any change is detected. * Try pnpm update --depth Infinity before pnpm audit --fix Adds a first-tier fallback that runs pnpm update --depth Infinity <dep> (with -r --include-workspace-root for workspaces) when the regular update is a no-op. This updates transitive dependencies in the lockfile without modifying any package.json (unlike pnpm audit --fix). If --depth Infinity is also a no-op we fall through to the existing audit --fix path. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <copilot@users.noreply.github.com>
1 parent 4a3538f commit 51f0bdc

24 files changed

Lines changed: 965 additions & 22 deletions

File tree

common/lib/dependabot/pull_request_creator/message_builder.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ def message
214214
sig { returns(String) }
215215
def solo_pr_name
216216
name = library? ? library_pr_name : application_pr_name
217+
name += " (via audit fix)" if dependencies.any? { |dep| dep.metadata[:audit_fix_used] }
217218
"#{name}#{pr_name_directory}"
218219
end
219220

common/spec/dependabot/pull_request_creator/message_builder_spec.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,12 @@ def commits_details(base:, head:)
239239
it { is_expected.to start_with("[Security] Bump business") }
240240
end
241241

242+
context "when a dependency has audit_fix_used metadata" do
243+
let(:metadata) { { audit_fix_used: true } }
244+
245+
it { is_expected.to eq("Bump business from 1.4.0 to 1.5.0 (via audit fix)") }
246+
end
247+
242248
context "with two dependencies" do
243249
let(:dependency2) do
244250
Dependabot::Dependency.new(
@@ -717,6 +723,15 @@ def commits_details(base:, head:)
717723
it { is_expected.to start_with("[Security] Update business") }
718724
end
719725

726+
context "when a dependency has audit_fix_used metadata" do
727+
let(:metadata) { { audit_fix_used: true } }
728+
729+
it "appends (via audit fix) to the PR name" do
730+
expect(pr_name)
731+
.to eq("Update business requirement from ~> 1.4.0 to ~> 1.5.0 (via audit fix)")
732+
end
733+
end
734+
720735
context "with two dependencies" do
721736
let(:dependency2) do
722737
Dependabot::Dependency.new(

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: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,27 @@ def run_npm_subdependency_updater(sub_dependencies:)
332332
sig { params(sub_dependencies: T::Array[Dependabot::Dependency]).returns(T::Hash[String, String]) }
333333
def run_npm8_subdependency_updater(sub_dependencies:)
334334
dependency_names = sub_dependencies.map(&:name)
335+
original_content = File.read(lockfile_basename)
336+
335337
NativeHelpers.run_npm8_subdependency_update_command(dependency_names)
336-
{ lockfile_basename => File.read(lockfile_basename) }
338+
339+
updated_content = File.read(lockfile_basename)
340+
if updated_content == original_content && Dependabot::Experiments.enabled?(:enable_audit_fix_fallback)
341+
# `npm update` is a no-op for transitive dependencies not listed in
342+
# any package.json (common in workspace repos). Fall back to
343+
# `npm audit fix` which can update these in the lockfile.
344+
# npm audit fix exits non-zero when vulnerabilities remain, so we
345+
# rescue and use whatever lockfile changes it managed to make.
346+
begin
347+
NativeHelpers.run_npm_audit_fix_command
348+
sub_dependencies.each { |dep| dep.metadata[:audit_fix_used] = true }
349+
rescue SharedHelpers::HelperSubprocessFailed
350+
Dependabot.logger.info("npm audit fix failed or partially fixed — continuing with any changes made")
351+
end
352+
updated_content = File.read(lockfile_basename)
353+
end
354+
355+
{ lockfile_basename => updated_content }
337356
end
338357

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

npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/pnpm_lockfile_updater.rb

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ def run_pnpm_update(pnpm_lock:, updated_pnpm_workspace_content: nil)
155155
File.write(".npmrc", npmrc_content(pnpm_lock))
156156

157157
SharedHelpers.with_git_configured(credentials: credentials) do
158+
original_content = File.read(pnpm_lock.name)
159+
158160
if updated_pnpm_workspace_content
159161
File.write("pnpm-workspace.yaml", updated_pnpm_workspace_content["pnpm-workspace.yaml"])
160162
else
@@ -164,7 +166,18 @@ def run_pnpm_update(pnpm_lock:, updated_pnpm_workspace_content: nil)
164166

165167
run_pnpm_install
166168

167-
File.read(pnpm_lock.name)
169+
updated_content = File.read(pnpm_lock.name)
170+
if updated_content == original_content && Dependabot::Experiments.enabled?(:enable_audit_fix_fallback)
171+
run_pnpm_deep_update_fallback
172+
updated_content = File.read(pnpm_lock.name)
173+
end
174+
175+
if updated_content == original_content && Dependabot::Experiments.enabled?(:enable_audit_fix_fallback)
176+
run_pnpm_audit_fix_fallback(pnpm_lock, original_content)
177+
updated_content = File.read(pnpm_lock.name)
178+
end
179+
180+
updated_content
168181
end
169182
end
170183
end
@@ -188,6 +201,54 @@ def run_pnpm_install
188201
)
189202
end
190203

204+
# Tries `pnpm update --depth Infinity <dep>` for each dependency as a
205+
# first-tier fallback when the regular update is a no-op (typically
206+
# transitive deps not listed in any package.json). Unlike `audit --fix`
207+
# this does not write `overrides` to package.json.
208+
sig { void }
209+
def run_pnpm_deep_update_fallback
210+
recursive = workspace_files.any?
211+
dependencies.each do |dep|
212+
NativeHelpers.run_pnpm_deep_update_command(dep.name, recursive: recursive)
213+
dep.metadata[:deep_update_used] = true
214+
end
215+
rescue SharedHelpers::HelperSubprocessFailed
216+
Dependabot.logger.info(
217+
"pnpm update --depth Infinity failed or partially fixed — continuing with any changes made"
218+
)
219+
end
220+
221+
# Runs `pnpm audit --fix` as a fallback when the primary update is a no-op.
222+
# `pnpm audit --fix` adds `overrides` to `package.json` — since we can
223+
# only return lockfile content from `run_pnpm_update`, any manifest
224+
# changes would produce inconsistent output. If audit-fix modifies a
225+
# package.json we revert both the manifest(s) and lockfile so the
226+
# overall operation behaves as a no-op.
227+
sig { params(pnpm_lock: Dependabot::DependencyFile, original_content: String).void }
228+
def run_pnpm_audit_fix_fallback(pnpm_lock, original_content)
229+
package_json_snapshots = Dir.glob("**/package.json").to_h { |f| [f, File.read(f)] }
230+
231+
begin
232+
NativeHelpers.run_pnpm_audit_fix_command
233+
run_pnpm_install
234+
235+
manifest_changed = package_json_snapshots.any? { |f, c| File.read(f) != c }
236+
if manifest_changed
237+
Dependabot.logger.info(
238+
"pnpm audit --fix modified package.json (overrides) — reverting fallback"
239+
)
240+
package_json_snapshots.each { |f, c| File.write(f, c) }
241+
File.write(pnpm_lock.name, original_content)
242+
else
243+
dependencies.each { |dep| dep.metadata[:audit_fix_used] = true }
244+
end
245+
rescue SharedHelpers::HelperSubprocessFailed
246+
Dependabot.logger.info(
247+
"pnpm audit --fix failed or partially fixed — continuing with any changes made"
248+
)
249+
end
250+
end
251+
191252
sig { returns(T::Array[Dependabot::DependencyFile]) }
192253
def workspace_files
193254
@workspace_files ||= T.let(

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,23 @@ def run_yarn_berry_subdependency_updater(yarn_lock:)
254254
["remove #{dep.name} #{yarn_berry_args}".strip, "remove <dep_name> #{yarn_berry_args}".strip]
255255
]
256256

257+
original_content = File.read(yarn_lock.name)
257258
Helpers.run_yarn_commands(*commands)
258-
{ yarn_lock.name => File.read(yarn_lock.name) }
259+
260+
updated_content = File.read(yarn_lock.name)
261+
if updated_content == original_content && Dependabot::Experiments.enabled?(:enable_audit_fix_fallback)
262+
begin
263+
NativeHelpers.run_yarn_audit_fix_command
264+
dep.metadata[:audit_fix_used] = true
265+
rescue SharedHelpers::HelperSubprocessFailed
266+
Dependabot.logger.info(
267+
"yarn npm audit --fix failed or partially fixed — continuing with any changes made"
268+
)
269+
end
270+
updated_content = File.read(yarn_lock.name)
271+
end
272+
273+
{ yarn_lock.name => updated_content }
259274
end
260275

261276
sig { returns(String) }

npm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,53 @@ 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+
# `--force` ignores checks for platform (os, cpu) and engines,
55+
# matching the flags used by run_npm8_subdependency_update_command.
56+
command = "audit fix --force --package-lock-only --ignore-scripts"
57+
fingerprint = "audit fix --force --package-lock-only --ignore-scripts"
58+
59+
Helpers.run_npm_command(command, fingerprint: fingerprint)
60+
end
61+
62+
sig { returns(String) }
63+
def self.run_pnpm_audit_fix_command
64+
# Fallback for transitive dependencies where `pnpm update` is a no-op.
65+
# `pnpm audit --fix` adds overrides to the manifest for vulnerable deps.
66+
Helpers.run_pnpm_command(
67+
"audit --fix",
68+
fingerprint: "audit --fix"
69+
)
70+
end
71+
72+
sig { params(dependency_name: String, recursive: T::Boolean).returns(String) }
73+
def self.run_pnpm_deep_update_command(dependency_name, recursive: false)
74+
# `pnpm update --depth Infinity <dep>` traverses the full dependency
75+
# graph, allowing transitive dependencies to be updated in the lockfile
76+
# without modifying any package.json (unlike `pnpm audit --fix`).
77+
# `-r --include-workspace-root` is required for workspace repos so the
78+
# update is applied across all packages.
79+
flags = recursive ? "-r --include-workspace-root " : ""
80+
Helpers.run_pnpm_command(
81+
"#{flags}update #{dependency_name} --depth Infinity --lockfile-only",
82+
fingerprint: "#{flags}update <dependency_name> --depth Infinity --lockfile-only"
83+
)
84+
end
85+
86+
sig { returns(String) }
87+
def self.run_yarn_audit_fix_command
88+
# Fallback for transitive dependencies where `yarn up -R` is a no-op.
89+
# `yarn npm audit --fix` updates vulnerable deps in the lockfile.
90+
Helpers.run_yarn_command(
91+
"npm audit --fix --mode update-lockfile",
92+
fingerprint: "npm audit --fix --mode update-lockfile"
93+
)
94+
end
4895
end
4996
end
5097
end

0 commit comments

Comments
 (0)