From 0f0601e6e6dc594005aedab546fff1eecedfd853 Mon Sep 17 00:00:00 2001 From: Kamil Bukum Date: Thu, 21 May 2026 17:38:43 -0500 Subject: [PATCH 1/5] Implement Yarn Berry security update handling and add corresponding tests --- .../file_updater/yarn_lockfile_updater.rb | 132 +++++++++- .../npm_and_yarn/file_updater_spec.rb | 36 +++ .../yarn_berry/security_update/package.json | 8 + .../yarn_berry/security_update/yarn.lock | 248 ++++++++++++++++++ 4 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/package.json create mode 100644 npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/yarn.lock diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb index beab8a2a152..7a765f38c23 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb @@ -221,7 +221,11 @@ def run_yarn_berry_top_level_updater(top_level_dependency_updates:, yarn_lock:) # the lockfile. if top_level_dependency_updates.all? { |dep| requirements_changed?(dep[:name]) } - Helpers.run_yarn_command("install #{yarn_berry_args}".strip) + # Yarn berry resolves ranges to the latest matching version, ignoring + # Dependabot's target version. To pin the exact version we install it + # explicitly, then rewrite the lockfile descriptor back to the original + # range — the same approach yarn classic uses via replaceLockfileDeclaration. + install_and_pin_berry_versions(top_level_dependency_updates, yarn_lock) else updates = top_level_dependency_updates.collect do |dep| dep[:name] @@ -243,6 +247,132 @@ def requirements_changed?(dependency_name) dep.requirements != dep.previous_requirements end + # Installs exact target versions via `yarn up dep@version`, then rewrites + # the lockfile descriptors from exact back to the original ranges. This + # mirrors yarn classic's replaceLockfileDeclaration approach. + sig do + params( + top_level_dependency_updates: T::Array[T::Hash[Symbol, T.untyped]], + yarn_lock: Dependabot::DependencyFile + ).void + end + def install_and_pin_berry_versions(top_level_dependency_updates, yarn_lock) + top_level_dependency_updates.each do |dep| + version = dep[:version] + next unless version + + dep_name = T.cast(dep[:name], String) + reqs = dep[:requirements] + next if reqs.nil? || reqs.empty? + next if reqs.any? { |req| req[:source] && req[:source][:type] == "git" } + + # Install the exact version to get correct checksum/deps in lockfile + Helpers.run_yarn_command( + "up #{dep_name}@#{version} #{yarn_berry_args}".strip, + fingerprint: "up @ #{yarn_berry_args}".strip + ) + + # Rewrite lockfile descriptors from exact version back to the range + reqs.each do |req| + requirement = req[:requirement] + next unless requirement + + replace_berry_lockfile_declaration( + yarn_lock, dep_name, T.cast(version, String), requirement + ) + end + end + end + + # Rewrites a yarn berry lockfile descriptor from an exact version key + # back to the semver range key. Reads the protocol from the existing + # lockfile entry rather than hardcoding it. + # + # Example: "axios@npm:1.15.2" → "axios@npm:^1.15.2" + # The resolved version, checksum, and dependencies remain unchanged. + sig do + params( + yarn_lock: Dependabot::DependencyFile, + dep_name: String, + version: String, + requirement: String + ).void + end + def replace_berry_lockfile_declaration(yarn_lock, dep_name, version, requirement) + lockfile_path = yarn_lock.name + return unless File.exist?(lockfile_path) + + content = File.read(lockfile_path) + exact_key = find_berry_lockfile_key(content, dep_name, version) + return unless exact_key + + protocol = extract_berry_protocol(exact_key, dep_name) + new_key = "#{dep_name}@#{protocol}#{requirement}" + + escaped_exact = Regexp.escape(exact_key) + updated_content = content.gsub(/^"#{escaped_exact}":/m, "\"#{new_key}\":") + File.write(lockfile_path, updated_content) + end + + # Finds the lockfile key matching the given dependency name and exact version. + # Handles scoped packages (e.g., @scope/pkg) and composite keys (e.g., "a@npm:1.0, a@npm:^1.0"). + sig { params(content: String, dep_name: String, version: String).returns(T.nilable(String)) } + def find_berry_lockfile_key(content, dep_name, version) + parsed = YAML.safe_load(content) + return unless parsed.is_a?(Hash) + + parsed.keys.find do |key| + next false unless key.is_a?(String) + + key.split(", ").any? { |part| berry_descriptor_matches?(part, dep_name, version) } + end + end + + # Checks if a single descriptor part matches the given dep name and version. + sig { params(part: String, dep_name: String, version: String).returns(T::Boolean) } + def berry_descriptor_matches?(part, dep_name, version) + name, descriptor = split_berry_descriptor(part) + name == dep_name && (descriptor&.end_with?(version) || false) + end + + # Splits a yarn berry descriptor into package name and version/range part. + # Handles scoped packages like @scope/pkg@npm:^1.0.0. + sig { params(descriptor: String).returns([String, T.nilable(String)]) } + def split_berry_descriptor(descriptor) + if descriptor.start_with?("@") + at_index = descriptor.index("@", 1) + return [descriptor, nil] unless at_index + + [T.must(descriptor[0...at_index]), descriptor[(at_index + 1)..]] + else + parts = descriptor.split("@", 2) + [T.must(parts[0]), parts[1]] + end + end + + # Extracts the protocol prefix (e.g., "npm:") from a yarn berry + # lockfile descriptor key. + sig { params(key: String, dep_name: String).returns(String) } + def extract_berry_protocol(key, dep_name) + # Match the part after dep_name@ to extract protocol + # e.g., "axios@npm:1.15.2" → "npm:" + # e.g., "@scope/pkg@npm:^1.0.0" → "npm:" + parts = key.split(", ").find { |p| p.include?(dep_name) } + return "" unless parts + + after_name = if parts.start_with?("@") + at_index = parts.index("@", 1) + return "" unless at_index + + parts[(at_index + 1)..] + else + parts.split("@", 2)[1] + end + + match = after_name&.match(/^([a-z]+:)/) + match ? T.must(match[1]) : "" + end + sig { params(yarn_lock: Dependabot::DependencyFile).returns(T::Hash[String, String]) } def run_yarn_berry_subdependency_updater(yarn_lock:) dep = T.must(sub_dependencies.first) diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater_spec.rb index b706674dced..6e156cbc367 100644 --- a/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater_spec.rb +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater_spec.rb @@ -3038,6 +3038,42 @@ .to include(%("acorn@npm:^5.0.0, acorn@npm:^5.1.2":\n version: 5.7.3)) end end + + context "with a security update that pins to exact target version" do + let(:project_name) { "yarn_berry/security_update" } + let(:files) { project_dependency_files(project_name) } + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + let(:dependency_name) { "axios" } + let(:version) { "1.15.2" } + let(:previous_version) { "1.15.0" } + let(:requirements) do + [{ + file: "package.json", + requirement: "^1.15.2", + groups: ["dependencies"], + source: nil + }] + end + let(:previous_requirements) do + [{ + file: "package.json", + requirement: "^1.15.0", + groups: ["dependencies"], + source: nil + }] + end + + it "resolves to the exact target version with the range descriptor" do + parsed_lockfile = YAML.safe_load(updated_yarn_lock.content) + axios_entry = parsed_lockfile.find { |k, _| k.is_a?(String) && k.include?("axios") } + + # Lockfile key should keep the range + expect(axios_entry&.first).to include("^1.15.2") + # Resolved version should be the exact target, not latest + expect(axios_entry&.last&.dig("version")).to eq("1.15.2") + end + end end ####################### diff --git a/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/package.json b/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/package.json new file mode 100644 index 00000000000..367814a5b63 --- /dev/null +++ b/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/package.json @@ -0,0 +1,8 @@ +{ + "name": "dependabot-axios-security-test", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "axios": "^1.15.0" + } +} diff --git a/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/yarn.lock b/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/yarn.lock new file mode 100644 index 00000000000..63deef42199 --- /dev/null +++ b/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/yarn.lock @@ -0,0 +1,248 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"axios@npm:^1.15.0": + version: 1.15.0 + resolution: "axios@npm:1.15.0" + dependencies: + follow-redirects: "npm:^1.15.11" + form-data: "npm:^4.0.5" + proxy-from-env: "npm:^2.1.0" + checksum: 10c0/47e0f860e98d4d7aa145e89ce0cae00e1fb0f1d2485f065c21fce955ddb1dba4103a46bd0e47acd18a27208a7f62c96249e620db575521b92a968619ab133409 + languageName: node + linkType: hard + +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + +"dependabot-axios-security-test@workspace:.": + version: 0.0.0-use.local + resolution: "dependabot-axios-security-test@workspace:." + dependencies: + axios: "npm:^1.15.0" + languageName: unknown + linkType: soft + +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + languageName: node + linkType: hard + +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af + languageName: node + linkType: hard + +"follow-redirects@npm:^1.15.11": + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/a1e2900163e6f1b4d1ed5c221b607f41decbab65534c63fe7e287e40a5d552a6496e7d9d7d976fa4ba77b4c51c11e5e9f683f10b43011ea11e442ff128d0e181 + languageName: node + linkType: hard + +"form-data@npm:^4.0.5": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b + languageName: node + linkType: hard + +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.6": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + +"hasown@npm:^2.0.2": + version: 2.0.3 + resolution: "hasown@npm:2.0.3" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/f5eb28c3fd0d3e4facd821c1eeee3836c37b70ab0b0fc532e8a39976e18fef43652415dadc52f8c7a5ff6d5ac93b7bef128789aa6f90f4e9b9a9083dce74ab38 + languageName: node + linkType: hard + +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 + languageName: node + linkType: hard From 82a3309f7a1e545311d46b755d269ce4dc937ea3 Mon Sep 17 00:00:00 2001 From: Kamil Bukum Date: Fri, 22 May 2026 17:02:51 -0500 Subject: [PATCH 2/5] Refactor Yarn Berry version handling to ensure correct version pinning during updates --- .../file_updater/yarn_lockfile_updater.rb | 87 +++++++++++++------ 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb index 7a765f38c23..cdfe8591200 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb @@ -221,11 +221,14 @@ def run_yarn_berry_top_level_updater(top_level_dependency_updates:, yarn_lock:) # the lockfile. if top_level_dependency_updates.all? { |dep| requirements_changed?(dep[:name]) } - # Yarn berry resolves ranges to the latest matching version, ignoring - # Dependabot's target version. To pin the exact version we install it - # explicitly, then rewrite the lockfile descriptor back to the original - # range — the same approach yarn classic uses via replaceLockfileDeclaration. - install_and_pin_berry_versions(top_level_dependency_updates, yarn_lock) + Helpers.run_yarn_command("install #{yarn_berry_args}".strip) + + # Yarn berry resolves ranges to the latest matching version, which + # may differ from Dependabot's target. If the lockfile resolved to a + # different version, re-install with the exact target and rewrite + # the lockfile descriptor back to the range — same approach as yarn + # classic's replaceLockfileDeclaration. + pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock) else updates = top_level_dependency_updates.collect do |dep| dep[:name] @@ -247,40 +250,70 @@ def requirements_changed?(dependency_name) dep.requirements != dep.previous_requirements end - # Installs exact target versions via `yarn up dep@version`, then rewrites - # the lockfile descriptors from exact back to the original ranges. This - # mirrors yarn classic's replaceLockfileDeclaration approach. + # Checks if yarn resolved to a different version than Dependabot's target + # and re-pins if needed. This handles security updates where yarn resolves + # to the latest in a range instead of the intended minimum safe version. sig do params( top_level_dependency_updates: T::Array[T::Hash[Symbol, T.untyped]], yarn_lock: Dependabot::DependencyFile ).void end - def install_and_pin_berry_versions(top_level_dependency_updates, yarn_lock) + def pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock) + lockfile_content = File.read(yarn_lock.name) + top_level_dependency_updates.each do |dep| - version = dep[:version] - next unless version + pin_berry_version_if_needed(dep, yarn_lock, lockfile_content) + end + end + + sig do + params( + dep: T::Hash[Symbol, T.untyped], + yarn_lock: Dependabot::DependencyFile, + lockfile_content: String + ).void + end + def pin_berry_version_if_needed(dep, yarn_lock, lockfile_content) + version = dep[:version] + return unless version - dep_name = T.cast(dep[:name], String) - reqs = dep[:requirements] - next if reqs.nil? || reqs.empty? - next if reqs.any? { |req| req[:source] && req[:source][:type] == "git" } + dep_name = T.cast(dep[:name], String) + reqs = dep[:requirements] + return if reqs.nil? || reqs.empty? + return if reqs.any? { |req| req[:source] && req[:source][:type] == "git" } - # Install the exact version to get correct checksum/deps in lockfile - Helpers.run_yarn_command( - "up #{dep_name}@#{version} #{yarn_berry_args}".strip, - fingerprint: "up @ #{yarn_berry_args}".strip + # Skip if yarn already resolved to the target version + return if berry_lockfile_version_matches?(lockfile_content, dep_name, T.cast(version, String)) + + # Yarn resolved to a different version — re-install with exact target + Helpers.run_yarn_command( + "up #{dep_name}@#{version} #{yarn_berry_args}".strip, + fingerprint: "up @ #{yarn_berry_args}".strip + ) + + # Rewrite lockfile descriptors from exact version back to the range + reqs.each do |req| + requirement = req[:requirement] + next unless requirement + + replace_berry_lockfile_declaration( + yarn_lock, dep_name, T.cast(version, String), requirement ) + end + end - # Rewrite lockfile descriptors from exact version back to the range - reqs.each do |req| - requirement = req[:requirement] - next unless requirement + # Checks if the yarn berry lockfile has the target version for a dependency. + sig { params(content: String, dep_name: String, version: String).returns(T::Boolean) } + def berry_lockfile_version_matches?(content, dep_name, version) + parsed = YAML.safe_load(content) + return false unless parsed.is_a?(Hash) - replace_berry_lockfile_declaration( - yarn_lock, dep_name, T.cast(version, String), requirement - ) - end + parsed.any? do |key, value| + next false unless key.is_a?(String) && value.is_a?(Hash) + + key.split(", ").any? { |part| split_berry_descriptor(part)[0] == dep_name } && + value["version"] == version end end From e0611dddf28385c0191061ad4ff3af71b4eceb69 Mon Sep 17 00:00:00 2001 From: Kamil Bukum Date: Fri, 22 May 2026 17:55:37 -0500 Subject: [PATCH 3/5] Add package.json backup and restore during Yarn Berry updates --- .../file_updater/yarn_lockfile_updater.rb | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb index cdfe8591200..693c20555b5 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb @@ -286,6 +286,9 @@ def pin_berry_version_if_needed(dep, yarn_lock, lockfile_content) # Skip if yarn already resolved to the target version return if berry_lockfile_version_matches?(lockfile_content, dep_name, T.cast(version, String)) + # Save the current package.json contents before yarn up modifies them + saved_package_jsons = save_package_jsons + # Yarn resolved to a different version — re-install with exact target Helpers.run_yarn_command( "up #{dep_name}@#{version} #{yarn_berry_args}".strip, @@ -301,6 +304,32 @@ def pin_berry_version_if_needed(dep, yarn_lock, lockfile_content) yarn_lock, dep_name, T.cast(version, String), requirement ) end + + # Restore original package.json files and run yarn install to normalize + # the lockfile with the correct range descriptors — same approach as + # yarn classic's replaceLockfileDeclaration flow. + restore_package_jsons(saved_package_jsons) + Helpers.run_yarn_command("install #{yarn_berry_args}".strip) + end + + # Saves the current content of all package.json files in the temp directory. + sig { returns(T::Hash[String, String]) } + def save_package_jsons + result = T.let({}, T::Hash[String, String]) + package_files.each do |file| + next unless File.exist?(file.name) + + result[file.name] = File.read(file.name) + end + result + end + + # Restores previously saved package.json contents. + sig { params(saved: T::Hash[String, String]).void } + def restore_package_jsons(saved) + saved.each do |path, content| + File.write(path, content) + end end # Checks if the yarn berry lockfile has the target version for a dependency. From 7b6c714e26276ab37a788a5ebcca4e46bc0ed5a9 Mon Sep 17 00:00:00 2001 From: Kamil Bukum Date: Fri, 22 May 2026 19:00:31 -0500 Subject: [PATCH 4/5] Refactor Yarn Berry lockfile handling to improve version pinning and parsing --- .../file_updater/yarn_lockfile_updater.rb | 147 +++++++----------- 1 file changed, 53 insertions(+), 94 deletions(-) diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb index 693c20555b5..6dadf557634 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb @@ -260,10 +260,11 @@ def requirements_changed?(dependency_name) ).void end def pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock) - lockfile_content = File.read(yarn_lock.name) + parsed_lockfile = parse_berry_lockfile(yarn_lock.name) + return unless parsed_lockfile top_level_dependency_updates.each do |dep| - pin_berry_version_if_needed(dep, yarn_lock, lockfile_content) + pin_berry_version_if_needed(dep, yarn_lock, parsed_lockfile) end end @@ -271,10 +272,10 @@ def pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock) params( dep: T::Hash[Symbol, T.untyped], yarn_lock: Dependabot::DependencyFile, - lockfile_content: String + parsed_lockfile: T::Hash[String, T.untyped] ).void end - def pin_berry_version_if_needed(dep, yarn_lock, lockfile_content) + def pin_berry_version_if_needed(dep, yarn_lock, parsed_lockfile) version = dep[:version] return unless version @@ -282,123 +283,88 @@ def pin_berry_version_if_needed(dep, yarn_lock, lockfile_content) reqs = dep[:requirements] return if reqs.nil? || reqs.empty? return if reqs.any? { |req| req[:source] && req[:source][:type] == "git" } + return if berry_lockfile_version_matches?(parsed_lockfile, dep_name, T.cast(version, String)) - # Skip if yarn already resolved to the target version - return if berry_lockfile_version_matches?(lockfile_content, dep_name, T.cast(version, String)) - - # Save the current package.json contents before yarn up modifies them saved_package_jsons = save_package_jsons - # Yarn resolved to a different version — re-install with exact target Helpers.run_yarn_command( "up #{dep_name}@#{version} #{yarn_berry_args}".strip, fingerprint: "up @ #{yarn_berry_args}".strip ) - # Rewrite lockfile descriptors from exact version back to the range reqs.each do |req| requirement = req[:requirement] next unless requirement - replace_berry_lockfile_declaration( - yarn_lock, dep_name, T.cast(version, String), requirement - ) + replace_berry_lockfile_declaration(yarn_lock, dep_name, T.cast(version, String), requirement) end - # Restore original package.json files and run yarn install to normalize - # the lockfile with the correct range descriptors — same approach as - # yarn classic's replaceLockfileDeclaration flow. + # Restore package.json and re-install to normalize lockfile descriptors, + # same as yarn classic's replaceLockfileDeclaration flow. restore_package_jsons(saved_package_jsons) Helpers.run_yarn_command("install #{yarn_berry_args}".strip) end - # Saves the current content of all package.json files in the temp directory. - sig { returns(T::Hash[String, String]) } - def save_package_jsons - result = T.let({}, T::Hash[String, String]) - package_files.each do |file| - next unless File.exist?(file.name) - - result[file.name] = File.read(file.name) - end - result - end + # Parses a yarn berry lockfile (YAML format). Returns nil if unparseable. + sig { params(lockfile_path: String).returns(T.nilable(T::Hash[String, T.untyped])) } + def parse_berry_lockfile(lockfile_path) + return unless File.exist?(lockfile_path) - # Restores previously saved package.json contents. - sig { params(saved: T::Hash[String, String]).void } - def restore_package_jsons(saved) - saved.each do |path, content| - File.write(path, content) - end + parsed = YAML.safe_load_file(lockfile_path) + parsed.is_a?(Hash) ? parsed : nil end - # Checks if the yarn berry lockfile has the target version for a dependency. - sig { params(content: String, dep_name: String, version: String).returns(T::Boolean) } - def berry_lockfile_version_matches?(content, dep_name, version) - parsed = YAML.safe_load(content) - return false unless parsed.is_a?(Hash) - + sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T::Boolean) } + def berry_lockfile_version_matches?(parsed, dep_name, version) parsed.any? do |key, value| - next false unless key.is_a?(String) && value.is_a?(Hash) + next false unless value.is_a?(Hash) - key.split(", ").any? { |part| split_berry_descriptor(part)[0] == dep_name } && + key.to_s.split(", ").any? { |part| split_berry_descriptor(part)[0] == dep_name } && value["version"] == version end end - # Rewrites a yarn berry lockfile descriptor from an exact version key - # back to the semver range key. Reads the protocol from the existing - # lockfile entry rather than hardcoding it. - # + # Rewrites a yarn berry lockfile descriptor key from exact version to range. # Example: "axios@npm:1.15.2" → "axios@npm:^1.15.2" - # The resolved version, checksum, and dependencies remain unchanged. sig do - params( - yarn_lock: Dependabot::DependencyFile, - dep_name: String, - version: String, - requirement: String - ).void + params(yarn_lock: Dependabot::DependencyFile, dep_name: String, version: String, requirement: String).void end def replace_berry_lockfile_declaration(yarn_lock, dep_name, version, requirement) lockfile_path = yarn_lock.name return unless File.exist?(lockfile_path) content = File.read(lockfile_path) - exact_key = find_berry_lockfile_key(content, dep_name, version) + parsed = parse_berry_lockfile(lockfile_path) + return unless parsed + + exact_key = find_berry_exact_key(parsed, dep_name, version) return unless exact_key - protocol = extract_berry_protocol(exact_key, dep_name) - new_key = "#{dep_name}@#{protocol}#{requirement}" + _, descriptor = split_berry_descriptor( + T.must(exact_key.split(", ").find { |p| split_berry_descriptor(p)[0] == dep_name }) + ) + protocol = descriptor&.match(/^([a-z]+:)/)&.then { |m| m[1] } || "" - escaped_exact = Regexp.escape(exact_key) - updated_content = content.gsub(/^"#{escaped_exact}":/m, "\"#{new_key}\":") - File.write(lockfile_path, updated_content) + new_key = "#{dep_name}@#{protocol}#{requirement}" + escaped = Regexp.escape(exact_key) + File.write(lockfile_path, content.gsub(/^"#{escaped}":/m, "\"#{new_key}\":")) end - # Finds the lockfile key matching the given dependency name and exact version. - # Handles scoped packages (e.g., @scope/pkg) and composite keys (e.g., "a@npm:1.0, a@npm:^1.0"). - sig { params(content: String, dep_name: String, version: String).returns(T.nilable(String)) } - def find_berry_lockfile_key(content, dep_name, version) - parsed = YAML.safe_load(content) - return unless parsed.is_a?(Hash) - + # Finds the lockfile key containing the given dep name with the exact version. + sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T.nilable(String)) } + def find_berry_exact_key(parsed, dep_name, version) parsed.keys.find do |key| next false unless key.is_a?(String) - key.split(", ").any? { |part| berry_descriptor_matches?(part, dep_name, version) } + key.split(", ").any? do |part| + name, desc = split_berry_descriptor(part) + name == dep_name && (desc&.end_with?(version) || false) + end end end - # Checks if a single descriptor part matches the given dep name and version. - sig { params(part: String, dep_name: String, version: String).returns(T::Boolean) } - def berry_descriptor_matches?(part, dep_name, version) - name, descriptor = split_berry_descriptor(part) - name == dep_name && (descriptor&.end_with?(version) || false) - end - - # Splits a yarn berry descriptor into package name and version/range part. - # Handles scoped packages like @scope/pkg@npm:^1.0.0. + # Splits "axios@npm:^1.15.2" into ["axios", "npm:^1.15.2"]. + # Handles scoped packages like "@scope/pkg@npm:^1.0.0". sig { params(descriptor: String).returns([String, T.nilable(String)]) } def split_berry_descriptor(descriptor) if descriptor.start_with?("@") @@ -412,27 +378,20 @@ def split_berry_descriptor(descriptor) end end - # Extracts the protocol prefix (e.g., "npm:") from a yarn berry - # lockfile descriptor key. - sig { params(key: String, dep_name: String).returns(String) } - def extract_berry_protocol(key, dep_name) - # Match the part after dep_name@ to extract protocol - # e.g., "axios@npm:1.15.2" → "npm:" - # e.g., "@scope/pkg@npm:^1.0.0" → "npm:" - parts = key.split(", ").find { |p| p.include?(dep_name) } - return "" unless parts - - after_name = if parts.start_with?("@") - at_index = parts.index("@", 1) - return "" unless at_index + sig { returns(T::Hash[String, String]) } + def save_package_jsons + result = T.let({}, T::Hash[String, String]) + package_files.each do |file| + next unless File.exist?(file.name) - parts[(at_index + 1)..] - else - parts.split("@", 2)[1] - end + result[file.name] = File.read(file.name) + end + result + end - match = after_name&.match(/^([a-z]+:)/) - match ? T.must(match[1]) : "" + sig { params(saved: T::Hash[String, String]).void } + def restore_package_jsons(saved) + saved.each { |path, content| File.write(path, content) } end sig { params(yarn_lock: Dependabot::DependencyFile).returns(T::Hash[String, String]) } From 1d0ba5f08798035f33b821876a4fe0da55fd8232 Mon Sep 17 00:00:00 2001 From: Kamil Bukum Date: Fri, 22 May 2026 20:44:24 -0500 Subject: [PATCH 5/5] Implement Berry lockfile handling and corresponding tests for security updates --- .../file_updater/berry_lockfile_handler.rb | 108 ++++++++++ .../file_updater/yarn_lockfile_updater.rb | 89 +------- .../berry_lockfile_handler_spec.rb | 195 ++++++++++++++++++ .../npm_and_yarn/file_updater_spec.rb | 40 +++- .../yarn_berry/security_update/package.json | 5 +- .../yarn_berry/security_update/yarn.lock | 30 ++- 6 files changed, 370 insertions(+), 97 deletions(-) create mode 100644 npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/berry_lockfile_handler.rb create mode 100644 npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/berry_lockfile_handler_spec.rb diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/berry_lockfile_handler.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/berry_lockfile_handler.rb new file mode 100644 index 00000000000..da9b4d02db1 --- /dev/null +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/berry_lockfile_handler.rb @@ -0,0 +1,108 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +require "yaml" + +require "dependabot/npm_and_yarn/file_updater" + +# Handles yarn berry lockfile manipulation — parsing descriptors, finding +# entries, and rewriting keys from exact versions back to ranges. This is +# the berry equivalent of yarn classic's replace-lockfile-declaration.ts. +module Dependabot + module NpmAndYarn + class FileUpdater < Dependabot::FileUpdaters::Base + class BerryLockfileHandler + extend T::Sig + + # Parses a yarn berry lockfile (YAML format). Returns nil if unparseable. + sig { params(lockfile_path: String).returns(T.nilable(T::Hash[String, T.untyped])) } + def self.parse(lockfile_path) + return unless File.exist?(lockfile_path) + + parsed = YAML.safe_load_file(lockfile_path) + parsed.is_a?(Hash) ? parsed : nil + end + + # Checks if the parsed lockfile has the target version for a dependency. + sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T::Boolean) } + def self.version_matches?(parsed, dep_name, version) + parsed.any? do |key, value| + next false unless value.is_a?(Hash) + + key.to_s.split(", ").any? { |part| split_descriptor(part)[0] == dep_name } && + value["version"] == version + end + end + + # Rewrites a lockfile descriptor key from exact version to range. + # Example: "axios@npm:1.15.2" → "axios@npm:^1.15.2" + # The resolved version, checksum, and dependencies remain unchanged. + sig do + params( + lockfile_path: String, + dep_name: String, + version: String, + requirement: String + ).void + end + def self.replace_declaration(lockfile_path, dep_name, version, requirement) + return unless File.exist?(lockfile_path) + + content = File.read(lockfile_path) + parsed = parse(lockfile_path) + return unless parsed + + exact_key = find_exact_key(parsed, dep_name, version) + return unless exact_key + + protocol = extract_protocol(exact_key, dep_name) + new_key = "#{dep_name}@#{protocol}#{requirement}" + + escaped = Regexp.escape(exact_key) + File.write(lockfile_path, content.gsub(/^"#{escaped}":/m, "\"#{new_key}\":")) + end + + # Finds the lockfile key containing the given dep name with exact version. + # Handles composite keys (e.g., "a@npm:1.0, a@npm:^1.0"). + sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T.nilable(String)) } + def self.find_exact_key(parsed, dep_name, version) + parsed.keys.find do |key| + next false unless key.is_a?(String) + + key.split(", ").any? do |part| + name, desc = split_descriptor(part) + name == dep_name && (desc&.end_with?(version) || false) + end + end + end + + # Splits a yarn berry descriptor into [package_name, version/range]. + # Handles scoped packages like @scope/pkg@npm:^1.0.0. + sig { params(descriptor: String).returns([String, T.nilable(String)]) } + def self.split_descriptor(descriptor) + if descriptor.start_with?("@") + at_index = descriptor.index("@", 1) + return [descriptor, nil] unless at_index + + [T.must(descriptor[0...at_index]), descriptor[(at_index + 1)..]] + else + parts = descriptor.split("@", 2) + [T.must(parts[0]), parts[1]] + end + end + + # Extracts the protocol prefix (e.g., "npm:") from a descriptor. + sig { params(key: String, dep_name: String).returns(String) } + def self.extract_protocol(key, dep_name) + part = key.split(", ").find { |p| split_descriptor(p)[0] == dep_name } + return "" unless part + + _, descriptor = split_descriptor(part) + match = descriptor&.match(/^([a-z]+:)/) + match ? T.must(match[1]) : "" + end + end + end + end +end diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb index 6dadf557634..7df8d283681 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb @@ -21,6 +21,7 @@ class YarnLockfileUpdater require_relative "npmrc_builder" require_relative "package_json_updater" require_relative "package_json_preparer" + require_relative "berry_lockfile_handler" extend T::Sig @@ -251,8 +252,9 @@ def requirements_changed?(dependency_name) end # Checks if yarn resolved to a different version than Dependabot's target - # and re-pins if needed. This handles security updates where yarn resolves - # to the latest in a range instead of the intended minimum safe version. + # and re-pins if needed. Yarn berry resolves ranges to the latest matching + # version, which can bypass Dependabot's version selection — including + # security updates (minimum safe version), ignore conditions, and cooldown. sig do params( top_level_dependency_updates: T::Array[T::Hash[Symbol, T.untyped]], @@ -260,11 +262,11 @@ def requirements_changed?(dependency_name) ).void end def pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock) - parsed_lockfile = parse_berry_lockfile(yarn_lock.name) - return unless parsed_lockfile + parsed = BerryLockfileHandler.parse(yarn_lock.name) + return unless parsed top_level_dependency_updates.each do |dep| - pin_berry_version_if_needed(dep, yarn_lock, parsed_lockfile) + pin_berry_version_if_needed(dep, yarn_lock, parsed) end end @@ -283,7 +285,7 @@ def pin_berry_version_if_needed(dep, yarn_lock, parsed_lockfile) reqs = dep[:requirements] return if reqs.nil? || reqs.empty? return if reqs.any? { |req| req[:source] && req[:source][:type] == "git" } - return if berry_lockfile_version_matches?(parsed_lockfile, dep_name, T.cast(version, String)) + return if BerryLockfileHandler.version_matches?(parsed_lockfile, dep_name, T.cast(version, String)) saved_package_jsons = save_package_jsons @@ -296,7 +298,7 @@ def pin_berry_version_if_needed(dep, yarn_lock, parsed_lockfile) requirement = req[:requirement] next unless requirement - replace_berry_lockfile_declaration(yarn_lock, dep_name, T.cast(version, String), requirement) + BerryLockfileHandler.replace_declaration(yarn_lock.name, dep_name, T.cast(version, String), requirement) end # Restore package.json and re-install to normalize lockfile descriptors, @@ -305,79 +307,6 @@ def pin_berry_version_if_needed(dep, yarn_lock, parsed_lockfile) Helpers.run_yarn_command("install #{yarn_berry_args}".strip) end - # Parses a yarn berry lockfile (YAML format). Returns nil if unparseable. - sig { params(lockfile_path: String).returns(T.nilable(T::Hash[String, T.untyped])) } - def parse_berry_lockfile(lockfile_path) - return unless File.exist?(lockfile_path) - - parsed = YAML.safe_load_file(lockfile_path) - parsed.is_a?(Hash) ? parsed : nil - end - - sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T::Boolean) } - def berry_lockfile_version_matches?(parsed, dep_name, version) - parsed.any? do |key, value| - next false unless value.is_a?(Hash) - - key.to_s.split(", ").any? { |part| split_berry_descriptor(part)[0] == dep_name } && - value["version"] == version - end - end - - # Rewrites a yarn berry lockfile descriptor key from exact version to range. - # Example: "axios@npm:1.15.2" → "axios@npm:^1.15.2" - sig do - params(yarn_lock: Dependabot::DependencyFile, dep_name: String, version: String, requirement: String).void - end - def replace_berry_lockfile_declaration(yarn_lock, dep_name, version, requirement) - lockfile_path = yarn_lock.name - return unless File.exist?(lockfile_path) - - content = File.read(lockfile_path) - parsed = parse_berry_lockfile(lockfile_path) - return unless parsed - - exact_key = find_berry_exact_key(parsed, dep_name, version) - return unless exact_key - - _, descriptor = split_berry_descriptor( - T.must(exact_key.split(", ").find { |p| split_berry_descriptor(p)[0] == dep_name }) - ) - protocol = descriptor&.match(/^([a-z]+:)/)&.then { |m| m[1] } || "" - - new_key = "#{dep_name}@#{protocol}#{requirement}" - escaped = Regexp.escape(exact_key) - File.write(lockfile_path, content.gsub(/^"#{escaped}":/m, "\"#{new_key}\":")) - end - - # Finds the lockfile key containing the given dep name with the exact version. - sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T.nilable(String)) } - def find_berry_exact_key(parsed, dep_name, version) - parsed.keys.find do |key| - next false unless key.is_a?(String) - - key.split(", ").any? do |part| - name, desc = split_berry_descriptor(part) - name == dep_name && (desc&.end_with?(version) || false) - end - end - end - - # Splits "axios@npm:^1.15.2" into ["axios", "npm:^1.15.2"]. - # Handles scoped packages like "@scope/pkg@npm:^1.0.0". - sig { params(descriptor: String).returns([String, T.nilable(String)]) } - def split_berry_descriptor(descriptor) - if descriptor.start_with?("@") - at_index = descriptor.index("@", 1) - return [descriptor, nil] unless at_index - - [T.must(descriptor[0...at_index]), descriptor[(at_index + 1)..]] - else - parts = descriptor.split("@", 2) - [T.must(parts[0]), parts[1]] - end - end - sig { returns(T::Hash[String, String]) } def save_package_jsons result = T.let({}, T::Hash[String, String]) diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/berry_lockfile_handler_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/berry_lockfile_handler_spec.rb new file mode 100644 index 00000000000..1e91466bcc2 --- /dev/null +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/berry_lockfile_handler_spec.rb @@ -0,0 +1,195 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/npm_and_yarn/file_updater/berry_lockfile_handler" + +RSpec.describe Dependabot::NpmAndYarn::FileUpdater::BerryLockfileHandler do + let(:fixture_path) do + File.join("spec", "fixtures", "projects", "yarn_berry", "security_update", "yarn.lock") + end + + describe ".parse" do + it "parses a valid yarn berry lockfile" do + parsed = described_class.parse(fixture_path) + expect(parsed).to be_a(Hash) + expect(parsed.keys).to include("__metadata") + end + + it "returns nil for a non-existent file" do + expect(described_class.parse("nonexistent.lock")).to be_nil + end + end + + describe ".split_descriptor" do + it "splits a simple descriptor" do + name, version = described_class.split_descriptor("axios@npm:^1.15.0") + expect(name).to eq("axios") + expect(version).to eq("npm:^1.15.0") + end + + it "splits a scoped package descriptor" do + name, version = described_class.split_descriptor("@scope/pkg@npm:^1.0.0") + expect(name).to eq("@scope/pkg") + expect(version).to eq("npm:^1.0.0") + end + + it "handles descriptor without version" do + name, version = described_class.split_descriptor("axios") + expect(name).to eq("axios") + expect(version).to be_nil + end + + it "handles scoped package without version" do + name, version = described_class.split_descriptor("@scope/pkg") + expect(name).to eq("@scope/pkg") + expect(version).to be_nil + end + end + + describe ".version_matches?" do + let(:parsed) do + { + "axios@npm:^1.15.0" => { "version" => "1.15.0", "resolution" => "axios@npm:1.15.0" }, + "@scope/pkg@npm:^2.0.0" => { "version" => "2.1.0", "resolution" => "@scope/pkg@npm:2.1.0" }, + "__metadata" => { "version" => 8 } + } + end + + it "returns true when version matches" do + expect(described_class.version_matches?(parsed, "axios", "1.15.0")).to be true + end + + it "returns false when version differs" do + expect(described_class.version_matches?(parsed, "axios", "1.15.2")).to be false + end + + it "returns false for unknown dependency" do + expect(described_class.version_matches?(parsed, "unknown-pkg", "1.0.0")).to be false + end + + it "handles scoped packages" do + expect(described_class.version_matches?(parsed, "@scope/pkg", "2.1.0")).to be true + expect(described_class.version_matches?(parsed, "@scope/pkg", "2.0.0")).to be false + end + + context "with composite keys" do + let(:parsed) do + { + "lodash@npm:1.3.1, lodash@npm:^1.3.1" => { "version" => "1.3.1" } + } + end + + it "matches composite keys" do + expect(described_class.version_matches?(parsed, "lodash", "1.3.1")).to be true + expect(described_class.version_matches?(parsed, "lodash", "1.3.0")).to be false + end + end + end + + describe ".find_exact_key" do + let(:parsed) do + { + "axios@npm:1.15.2" => { "version" => "1.15.2" }, + "lodash@npm:^1.3.1" => { "version" => "1.3.1" }, + "@scope/pkg@npm:2.0.0" => { "version" => "2.0.0" } + } + end + + it "finds exact version key" do + expect(described_class.find_exact_key(parsed, "axios", "1.15.2")).to eq("axios@npm:1.15.2") + end + + it "finds scoped package key" do + expect(described_class.find_exact_key(parsed, "@scope/pkg", "2.0.0")).to eq("@scope/pkg@npm:2.0.0") + end + + it "returns nil when not found" do + expect(described_class.find_exact_key(parsed, "axios", "9.9.9")).to be_nil + end + + it "does not match range keys" do + expect(described_class.find_exact_key(parsed, "lodash", "1.3")).to be_nil + end + end + + describe ".extract_protocol" do + it "extracts npm protocol" do + expect(described_class.extract_protocol("axios@npm:1.15.2", "axios")).to eq("npm:") + end + + it "extracts protocol from scoped package" do + expect(described_class.extract_protocol("@scope/pkg@npm:^1.0.0", "@scope/pkg")).to eq("npm:") + end + + it "extracts protocol from composite key" do + expect(described_class.extract_protocol("lodash@npm:1.3.1, lodash@npm:^1.3.1", "lodash")).to eq("npm:") + end + + it "returns empty string when no protocol" do + expect(described_class.extract_protocol("axios@1.15.2", "axios")).to eq("") + end + end + + describe ".replace_declaration" do + let(:tmp_dir) { Dir.mktmpdir } + let(:lockfile_path) { File.join(tmp_dir, "yarn.lock") } + + after { FileUtils.rm_rf(tmp_dir) } + + it "replaces exact descriptor with range" do + File.write(lockfile_path, <<~YAML) + "axios@npm:1.15.2": + version: 1.15.2 + resolution: "axios@npm:1.15.2" + checksum: abc123 + YAML + + described_class.replace_declaration(lockfile_path, "axios", "1.15.2", "^1.15.2") + + content = File.read(lockfile_path) + expect(content).to include('"axios@npm:^1.15.2":') + expect(content).not_to include('"axios@npm:1.15.2":') + expect(content).to include("version: 1.15.2") + expect(content).to include('resolution: "axios@npm:1.15.2"') + end + + it "handles tilde ranges" do + File.write(lockfile_path, <<~YAML) + "lodash@npm:1.3.1": + version: 1.3.1 + resolution: "lodash@npm:1.3.1" + YAML + + described_class.replace_declaration(lockfile_path, "lodash", "1.3.1", "~1.3.1") + + content = File.read(lockfile_path) + expect(content).to include('"lodash@npm:~1.3.1":') + end + + it "handles scoped packages" do + File.write(lockfile_path, <<~YAML) + "@scope/pkg@npm:2.0.0": + version: 2.0.0 + resolution: "@scope/pkg@npm:2.0.0" + YAML + + described_class.replace_declaration(lockfile_path, "@scope/pkg", "2.0.0", "^2.0.0") + + content = File.read(lockfile_path) + expect(content).to include('"@scope/pkg@npm:^2.0.0":') + end + + it "does nothing when exact key not found" do + original = <<~YAML + "axios@npm:^1.15.0": + version: 1.15.0 + YAML + File.write(lockfile_path, original) + + described_class.replace_declaration(lockfile_path, "axios", "1.15.2", "^1.15.2") + + expect(File.read(lockfile_path)).to eq(original) + end + end +end diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater_spec.rb index 6e156cbc367..fe0ba85bddc 100644 --- a/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater_spec.rb +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater_spec.rb @@ -3039,7 +3039,7 @@ end end - context "with a security update that pins to exact target version" do + context "when the target version differs from latest in range (security update)" do let(:project_name) { "yarn_berry/security_update" } let(:files) { project_dependency_files(project_name) } let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } @@ -3064,16 +3064,48 @@ }] end - it "resolves to the exact target version with the range descriptor" do + it "pins to the exact target version with the caret range descriptor" do parsed_lockfile = YAML.safe_load(updated_yarn_lock.content) axios_entry = parsed_lockfile.find { |k, _| k.is_a?(String) && k.include?("axios") } - # Lockfile key should keep the range expect(axios_entry&.first).to include("^1.15.2") - # Resolved version should be the exact target, not latest expect(axios_entry&.last&.dig("version")).to eq("1.15.2") end end + + context "when the target version differs from latest in range (version update with ignore)" do + let(:project_name) { "yarn_berry/security_update" } + let(:files) { project_dependency_files(project_name) } + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + let(:dependency_name) { "lodash" } + let(:version) { "4.17.10" } + let(:previous_version) { "4.17.0" } + let(:requirements) do + [{ + file: "package.json", + requirement: "~4.17.10", + groups: ["dependencies"], + source: nil + }] + end + let(:previous_requirements) do + [{ + file: "package.json", + requirement: "~4.17.0", + groups: ["dependencies"], + source: nil + }] + end + + it "pins to the exact target version with the tilde range descriptor" do + parsed_lockfile = YAML.safe_load(updated_yarn_lock.content) + lodash_entry = parsed_lockfile.find { |k, _| k.is_a?(String) && k.include?("lodash") } + + expect(lodash_entry&.first).to include("~4.17.10") + expect(lodash_entry&.last&.dig("version")).to eq("4.17.10") + end + end end ####################### diff --git a/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/package.json b/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/package.json index 367814a5b63..412861fcd7b 100644 --- a/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/package.json +++ b/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/package.json @@ -1,8 +1,9 @@ { - "name": "dependabot-axios-security-test", + "name": "yarn-berry-combined-test", "version": "1.0.0", "packageManager": "yarn@4.12.0", "dependencies": { - "axios": "^1.15.0" + "axios": "^1.15.0", + "lodash": "~4.17.0" } } diff --git a/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/yarn.lock b/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/yarn.lock index 63deef42199..d8c4d18f00c 100644 --- a/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/yarn.lock +++ b/npm_and_yarn/spec/fixtures/projects/yarn_berry/security_update/yarn.lock @@ -63,14 +63,6 @@ __metadata: languageName: node linkType: hard -"dependabot-axios-security-test@workspace:.": - version: 0.0.0-use.local - resolution: "dependabot-axios-security-test@workspace:." - dependencies: - axios: "npm:^1.15.0" - languageName: unknown - linkType: soft - "dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -97,11 +89,11 @@ __metadata: linkType: hard "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": - version: 1.1.1 - resolution: "es-object-atoms@npm:1.1.1" + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" dependencies: es-errors: "npm:^1.3.0" - checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + checksum: 10c0/1772861f094f739d6f41b579cfb9a18579daffeb434552a370a5fbef50a32d22227e27b63fdbb757b7ddd429d1b42fe52ccae7966d9302a2ec221b6f1b41bbc4 languageName: node linkType: hard @@ -217,6 +209,13 @@ __metadata: languageName: node linkType: hard +"lodash@npm:~4.17.0": + version: 4.17.0 + resolution: "lodash@npm:4.17.0" + checksum: 10c0/48714dd7475af2a8de2a1e903eb7066c5f883b6d3ee3345d09882146df8c4377208b4331c2911f7005ff5b80d3951fd422545dc82ee42d9768143119f3e5b08e + languageName: node + linkType: hard + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -246,3 +245,12 @@ __metadata: checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 languageName: node linkType: hard + +"yarn-berry-combined-test@workspace:.": + version: 0.0.0-use.local + resolution: "yarn-berry-combined-test@workspace:." + dependencies: + axios: "npm:^1.15.0" + lodash: "npm:~4.17.0" + languageName: unknown + linkType: soft