Skip to content

Commit 10db257

Browse files
authored
Fix yarn berry security updates resolving to latest instead of target version (#15091)
* Implement Yarn Berry security update handling and add corresponding tests * Refactor Yarn Berry version handling to ensure correct version pinning during updates
1 parent 04ab85a commit 10db257

6 files changed

Lines changed: 716 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
require "yaml"
6+
7+
require "dependabot/npm_and_yarn/file_updater"
8+
9+
# Handles yarn berry lockfile manipulation — parsing descriptors, finding
10+
# entries, and rewriting keys from exact versions back to ranges. This is
11+
# the berry equivalent of yarn classic's replace-lockfile-declaration.ts.
12+
module Dependabot
13+
module NpmAndYarn
14+
class FileUpdater < Dependabot::FileUpdaters::Base
15+
class BerryLockfileHandler
16+
extend T::Sig
17+
18+
# Parses a yarn berry lockfile (YAML format). Returns nil if unparseable.
19+
sig { params(lockfile_path: String).returns(T.nilable(T::Hash[String, T.untyped])) }
20+
def self.parse(lockfile_path)
21+
return unless File.exist?(lockfile_path)
22+
23+
parsed = YAML.safe_load_file(lockfile_path)
24+
parsed.is_a?(Hash) ? parsed : nil
25+
end
26+
27+
# Checks if the parsed lockfile has the target version for a dependency.
28+
sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T::Boolean) }
29+
def self.version_matches?(parsed, dep_name, version)
30+
parsed.any? do |key, value|
31+
next false unless value.is_a?(Hash)
32+
33+
key.to_s.split(", ").any? { |part| split_descriptor(part)[0] == dep_name } &&
34+
value["version"] == version
35+
end
36+
end
37+
38+
# Rewrites a lockfile descriptor key from exact version to range.
39+
# Example: "axios@npm:1.15.2" → "axios@npm:^1.15.2"
40+
# The resolved version, checksum, and dependencies remain unchanged.
41+
sig do
42+
params(
43+
lockfile_path: String,
44+
dep_name: String,
45+
version: String,
46+
requirement: String
47+
).void
48+
end
49+
def self.replace_declaration(lockfile_path, dep_name, version, requirement)
50+
return unless File.exist?(lockfile_path)
51+
52+
content = File.read(lockfile_path)
53+
parsed = parse(lockfile_path)
54+
return unless parsed
55+
56+
exact_key = find_exact_key(parsed, dep_name, version)
57+
return unless exact_key
58+
59+
protocol = extract_protocol(exact_key, dep_name)
60+
new_key = "#{dep_name}@#{protocol}#{requirement}"
61+
62+
escaped = Regexp.escape(exact_key)
63+
File.write(lockfile_path, content.gsub(/^"#{escaped}":/m, "\"#{new_key}\":"))
64+
end
65+
66+
# Finds the lockfile key containing the given dep name with exact version.
67+
# Handles composite keys (e.g., "a@npm:1.0, a@npm:^1.0").
68+
sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T.nilable(String)) }
69+
def self.find_exact_key(parsed, dep_name, version)
70+
parsed.keys.find do |key|
71+
next false unless key.is_a?(String)
72+
73+
key.split(", ").any? do |part|
74+
name, desc = split_descriptor(part)
75+
name == dep_name && (desc&.end_with?(version) || false)
76+
end
77+
end
78+
end
79+
80+
# Splits a yarn berry descriptor into [package_name, version/range].
81+
# Handles scoped packages like @scope/pkg@npm:^1.0.0.
82+
sig { params(descriptor: String).returns([String, T.nilable(String)]) }
83+
def self.split_descriptor(descriptor)
84+
if descriptor.start_with?("@")
85+
at_index = descriptor.index("@", 1)
86+
return [descriptor, nil] unless at_index
87+
88+
[T.must(descriptor[0...at_index]), descriptor[(at_index + 1)..]]
89+
else
90+
parts = descriptor.split("@", 2)
91+
[T.must(parts[0]), parts[1]]
92+
end
93+
end
94+
95+
# Extracts the protocol prefix (e.g., "npm:") from a descriptor.
96+
sig { params(key: String, dep_name: String).returns(String) }
97+
def self.extract_protocol(key, dep_name)
98+
part = key.split(", ").find { |p| split_descriptor(p)[0] == dep_name }
99+
return "" unless part
100+
101+
_, descriptor = split_descriptor(part)
102+
match = descriptor&.match(/^([a-z]+:)/)
103+
match ? T.must(match[1]) : ""
104+
end
105+
end
106+
end
107+
end
108+
end

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class YarnLockfileUpdater
2121
require_relative "npmrc_builder"
2222
require_relative "package_json_updater"
2323
require_relative "package_json_preparer"
24+
require_relative "berry_lockfile_handler"
2425

2526
extend T::Sig
2627

@@ -222,6 +223,13 @@ def run_yarn_berry_top_level_updater(top_level_dependency_updates:, yarn_lock:)
222223

223224
if top_level_dependency_updates.all? { |dep| requirements_changed?(dep[:name]) }
224225
Helpers.run_yarn_command("install #{yarn_berry_args}".strip)
226+
227+
# Yarn berry resolves ranges to the latest matching version, which
228+
# may differ from Dependabot's target. If the lockfile resolved to a
229+
# different version, re-install with the exact target and rewrite
230+
# the lockfile descriptor back to the range — same approach as yarn
231+
# classic's replaceLockfileDeclaration.
232+
pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock)
225233
else
226234
updates = top_level_dependency_updates.collect do |dep|
227235
dep[:name]
@@ -243,6 +251,78 @@ def requirements_changed?(dependency_name)
243251
dep.requirements != dep.previous_requirements
244252
end
245253

254+
# Checks if yarn resolved to a different version than Dependabot's target
255+
# and re-pins if needed. Yarn berry resolves ranges to the latest matching
256+
# version, which can bypass Dependabot's version selection — including
257+
# security updates (minimum safe version), ignore conditions, and cooldown.
258+
sig do
259+
params(
260+
top_level_dependency_updates: T::Array[T::Hash[Symbol, T.untyped]],
261+
yarn_lock: Dependabot::DependencyFile
262+
).void
263+
end
264+
def pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock)
265+
parsed = BerryLockfileHandler.parse(yarn_lock.name)
266+
return unless parsed
267+
268+
top_level_dependency_updates.each do |dep|
269+
pin_berry_version_if_needed(dep, yarn_lock, parsed)
270+
end
271+
end
272+
273+
sig do
274+
params(
275+
dep: T::Hash[Symbol, T.untyped],
276+
yarn_lock: Dependabot::DependencyFile,
277+
parsed_lockfile: T::Hash[String, T.untyped]
278+
).void
279+
end
280+
def pin_berry_version_if_needed(dep, yarn_lock, parsed_lockfile)
281+
version = dep[:version]
282+
return unless version
283+
284+
dep_name = T.cast(dep[:name], String)
285+
reqs = dep[:requirements]
286+
return if reqs.nil? || reqs.empty?
287+
return if reqs.any? { |req| req[:source] && req[:source][:type] == "git" }
288+
return if BerryLockfileHandler.version_matches?(parsed_lockfile, dep_name, T.cast(version, String))
289+
290+
saved_package_jsons = save_package_jsons
291+
292+
Helpers.run_yarn_command(
293+
"up #{dep_name}@#{version} #{yarn_berry_args}".strip,
294+
fingerprint: "up <dep>@<version> #{yarn_berry_args}".strip
295+
)
296+
297+
reqs.each do |req|
298+
requirement = req[:requirement]
299+
next unless requirement
300+
301+
BerryLockfileHandler.replace_declaration(yarn_lock.name, dep_name, T.cast(version, String), requirement)
302+
end
303+
304+
# Restore package.json and re-install to normalize lockfile descriptors,
305+
# same as yarn classic's replaceLockfileDeclaration flow.
306+
restore_package_jsons(saved_package_jsons)
307+
Helpers.run_yarn_command("install #{yarn_berry_args}".strip)
308+
end
309+
310+
sig { returns(T::Hash[String, String]) }
311+
def save_package_jsons
312+
result = T.let({}, T::Hash[String, String])
313+
package_files.each do |file|
314+
next unless File.exist?(file.name)
315+
316+
result[file.name] = File.read(file.name)
317+
end
318+
result
319+
end
320+
321+
sig { params(saved: T::Hash[String, String]).void }
322+
def restore_package_jsons(saved)
323+
saved.each { |path, content| File.write(path, content) }
324+
end
325+
246326
sig { params(yarn_lock: Dependabot::DependencyFile).returns(T::Hash[String, String]) }
247327
def run_yarn_berry_subdependency_updater(yarn_lock:)
248328
dep = T.must(sub_dependencies.first)

0 commit comments

Comments
 (0)