dependabot-npm_and_yarn 0.131.3 → 0.132.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (23) hide show
  1. checksums.yaml +4 -4
  2. data/helpers/lib/npm6/updater.js +1 -0
  3. data/helpers/lib/npm7/index.js +5 -0
  4. data/helpers/lib/npm7/peer-dependency-checker.js +77 -0
  5. data/helpers/test/npm7/conflicting-dependency-parser.test.js +67 -0
  6. data/helpers/test/npm7/fixtures/conflicting-dependency-parser/deeply-nested/package-lock.json +1291 -0
  7. data/helpers/test/npm7/fixtures/conflicting-dependency-parser/deeply-nested/package.json +14 -0
  8. data/helpers/test/npm7/fixtures/conflicting-dependency-parser/nested/package-lock.json +411 -0
  9. data/helpers/test/npm7/fixtures/conflicting-dependency-parser/nested/package.json +14 -0
  10. data/helpers/test/npm7/fixtures/conflicting-dependency-parser/simple/package-lock.json +64 -0
  11. data/helpers/test/npm7/fixtures/conflicting-dependency-parser/simple/package.json +14 -0
  12. data/helpers/test/npm7/fixtures/peer-dependency-checker/peer_dependency/package-lock.json +290 -0
  13. data/helpers/test/npm7/fixtures/peer-dependency-checker/peer_dependency/package.json +23 -0
  14. data/helpers/test/npm7/fixtures/peer-dependency-checker/peer_dependency_multiple/package-lock.json +965 -0
  15. data/helpers/test/npm7/fixtures/peer-dependency-checker/peer_dependency_multiple/package.json +10 -0
  16. data/helpers/test/npm7/helpers.js +21 -0
  17. data/helpers/test/npm7/peer-dependency-checker.test.js +107 -0
  18. data/helpers/yarn.lock +1198 -1232
  19. data/lib/dependabot/npm_and_yarn/dependency_files_filterer.rb +3 -3
  20. data/lib/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater.rb +252 -53
  21. data/lib/dependabot/npm_and_yarn/update_checker/subdependency_version_resolver.rb +1 -2
  22. data/lib/dependabot/npm_and_yarn/update_checker/version_resolver.rb +1 -2
  23. metadata +18 -3
@@ -20,7 +20,7 @@ module Dependabot
20
20
  dependency_files.select do |file|
21
21
  package_files_requiring_update.include?(file) ||
22
22
  package_required_lockfile?(file) ||
23
- yarn_workspaces_lockfile?(file)
23
+ workspaces_lockfile?(file)
24
24
  end
25
25
  end
26
26
  end
@@ -51,8 +51,8 @@ module Dependabot
51
51
  end
52
52
  end
53
53
 
54
- def yarn_workspaces_lockfile?(lockfile)
55
- return false unless lockfile.name == "yarn.lock"
54
+ def workspaces_lockfile?(lockfile)
55
+ return false unless ["yarn.lock", "package-lock.json"].include?(lockfile.name)
56
56
  return false unless parsed_root_package_json["workspaces"]
57
57
 
58
58
  updated_dependencies_in_lockfile?(lockfile)
@@ -37,7 +37,7 @@ module Dependabot
37
37
  run_current_npm_update(lockfile_name: lockfile_name, lockfile_content: lockfile.content)
38
38
  end
39
39
  updated_content = updated_files.fetch(lockfile_name)
40
- post_process_npm_lockfile(lockfile.content, updated_content)
40
+ post_process_npm_lockfile(lockfile.content, updated_content, lockfile.name)
41
41
  end
42
42
  rescue SharedHelpers::HelperSubprocessFailed => e
43
43
  handle_npm_updater_error(e, lockfile)
@@ -47,15 +47,22 @@ module Dependabot
47
47
 
48
48
  attr_reader :dependencies, :dependency_files, :credentials
49
49
 
50
- UNREACHABLE_GIT =
51
- /ls-remote (?:(-h -t)|(--tags --heads)) (?<url>.*)/.freeze
52
- FORBIDDEN_PACKAGE =
53
- %r{(?<package_req>[^/]+) - (Forbidden|Unauthorized)}.freeze
50
+ UNREACHABLE_GIT = /fatal: repository '(?<url>.*)' not found/.freeze
51
+ FORBIDDEN_GIT = /fatal: Authentication failed for '(?<url>.*)'/.freeze
52
+ FORBIDDEN_PACKAGE = %r{(?<package_req>[^/]+) - (Forbidden|Unauthorized)}.freeze
54
53
  FORBIDDEN_PACKAGE_403 = %r{^403\sForbidden\s
55
54
  -\sGET\shttps?://(?<source>[^/]+)/(?<package_req>[^/\s]+)}x.freeze
56
55
  MISSING_PACKAGE = %r{(?<package_req>[^/]+) - Not found}.freeze
57
56
  INVALID_PACKAGE = /Can't install (?<package_req>.*): Missing/.freeze
58
57
 
58
+ # TODO: look into fixing this in npm, seems like a bug in the git
59
+ # downloader introduced in npm 7
60
+ #
61
+ # NOTE: error message returned from arborist/npm 7 when trying to
62
+ # fetching a invalid/non-existent git ref
63
+ NPM7_MISSING_GIT_REF = /already exists and is not an empty directory/.freeze
64
+ NPM6_MISSING_GIT_REF = /did not match any file\(s\) known to git/.freeze
65
+
59
66
  def top_level_dependencies
60
67
  dependencies.select(&:top_level?)
61
68
  end
@@ -65,11 +72,9 @@ module Dependabot
65
72
  end
66
73
 
67
74
  def updatable_dependencies(lockfile)
68
- lockfile_dir = Pathname.new(lockfile.name).dirname.to_s
69
75
  dependencies.reject do |dependency|
70
76
  dependency_up_to_date?(lockfile, dependency) ||
71
- top_level_dependency_update_not_required?(dependency,
72
- lockfile_dir)
77
+ top_level_dependency_update_not_required?(dependency, lockfile)
73
78
  end
74
79
  end
75
80
 
@@ -96,17 +101,24 @@ module Dependabot
96
101
  existing_dep&.version == dependency.version
97
102
  end
98
103
 
99
- # Prevent changes to the lockfile when the dependency has been
104
+ # NOTE: Prevent changes to npm 6 lockfiles when the dependency has been
100
105
  # required in a package.json outside the current folder (e.g. lerna
101
- # proj)
102
- def top_level_dependency_update_not_required?(dependency,
103
- lockfile_dir)
106
+ # proj). npm 7 introduces workspace support so we explitly want to
107
+ # update the root lockfile and check if the dependency is in the
108
+ # lockfile
109
+ def top_level_dependency_update_not_required?(dependency, lockfile)
110
+ lockfile_dir = Pathname.new(lockfile.name).dirname.to_s
111
+
104
112
  requirements_for_path = dependency.requirements.select do |req|
105
113
  req_dir = Pathname.new(req[:file]).dirname.to_s
106
114
  req_dir == lockfile_dir
107
115
  end
108
116
 
109
- dependency.top_level? && requirements_for_path.empty?
117
+ dependency_in_lockfile = lockfile_dependencies(lockfile).any? do |dep|
118
+ dep.name == dependency.name
119
+ end
120
+
121
+ dependency.top_level? && requirements_for_path.empty? && !dependency_in_lockfile
110
122
  end
111
123
 
112
124
  def run_current_npm_update(lockfile_name:, lockfile_content:)
@@ -152,29 +164,133 @@ module Dependabot
152
164
  end
153
165
 
154
166
  def run_npm_top_level_updater(lockfile_name:, top_level_dependency_updates:, lockfile_content:)
155
- npm_version = Dependabot::NpmAndYarn::Helpers.npm_version(lockfile_content)
156
- Dependabot.logger.info(npm_version)
157
-
158
- SharedHelpers.run_helper_subprocess(
159
- command: NativeHelpers.helper_path,
160
- function: "npm6:update",
161
- args: [
162
- Dir.pwd,
163
- lockfile_name,
164
- top_level_dependency_updates
165
- ]
167
+ if npm7?(lockfile_content)
168
+ run_npm_7_top_level_updater(
169
+ lockfile_name: lockfile_name,
170
+ top_level_dependency_updates: top_level_dependency_updates
171
+ )
172
+ else
173
+ SharedHelpers.run_helper_subprocess(
174
+ command: NativeHelpers.helper_path,
175
+ function: "npm6:update",
176
+ args: [
177
+ Dir.pwd,
178
+ lockfile_name,
179
+ top_level_dependency_updates
180
+ ]
181
+ )
182
+ end
183
+ end
184
+
185
+ def run_npm_7_top_level_updater(lockfile_name:, top_level_dependency_updates:)
186
+ # - `--dry-run=false` the updater sets a global .npmrc with dry-run: true to
187
+ # work around an issue in npm 6, we don't want that here
188
+ # - `--force` ignores checks for platform (os, cpu) and engines
189
+ # - `--ignore-scripts` disables prepare and prepack scripts which are run
190
+ # when installing git dependencies
191
+ flattenend_manifest_dependencies = flattenend_manifest_dependencies_for_lockfile_name(lockfile_name)
192
+ install_args = npm_top_level_updater_args(
193
+ top_level_dependency_updates: top_level_dependency_updates,
194
+ flattenend_manifest_dependencies: flattenend_manifest_dependencies
166
195
  )
196
+ command = [
197
+ "npm",
198
+ "install",
199
+ *install_args,
200
+ "--force",
201
+ "--dry-run",
202
+ "false",
203
+ "--ignore-scripts",
204
+ "--package-lock-only"
205
+ ].join(" ")
206
+ SharedHelpers.run_shell_command(command)
207
+ { lockfile_name => File.read(lockfile_name) }
167
208
  end
168
209
 
169
210
  def run_npm_subdependency_updater(lockfile_name:, lockfile_content:)
170
- npm_version = Dependabot::NpmAndYarn::Helpers.npm_version(lockfile_content)
171
- Dependabot.logger.info(npm_version)
211
+ if npm7?(lockfile_content)
212
+ run_npm_7_subdependency_updater(lockfile_name: lockfile_name)
213
+ else
214
+ SharedHelpers.run_helper_subprocess(
215
+ command: NativeHelpers.helper_path,
216
+ function: "npm6:updateSubdependency",
217
+ args: [Dir.pwd, lockfile_name, sub_dependencies.map(&:to_h)]
218
+ )
219
+ end
220
+ end
172
221
 
173
- SharedHelpers.run_helper_subprocess(
174
- command: NativeHelpers.helper_path,
175
- function: "npm6:updateSubdependency",
176
- args: [Dir.pwd, lockfile_name, sub_dependencies.map(&:to_h)]
177
- )
222
+ def run_npm_7_subdependency_updater(lockfile_name:)
223
+ dependency_names = sub_dependencies.map(&:name)
224
+ # - `--dry-run=false` the updater sets a global .npmrc with dry-run: true to
225
+ # work around an issue in npm 6, we don't want that here
226
+ # - `--force` ignores checks for platform (os, cpu) and engines
227
+ # - `--ignore-scripts` disables prepare and prepack scripts which are run
228
+ # when installing git dependencies
229
+ command = [
230
+ "npm",
231
+ "update",
232
+ *dependency_names,
233
+ "--force",
234
+ "--dry-run",
235
+ "false",
236
+ "--ignore-scripts",
237
+ "--package-lock-only"
238
+ ].join(" ")
239
+ SharedHelpers.run_shell_command(command)
240
+ { lockfile_name => File.read(lockfile_name) }
241
+ end
242
+
243
+ # TODO: Update the npm 6 updater to use these args as we currently do
244
+ # the same in the js updater helper, we've kept it seperate for the npm
245
+ # 7 rollout
246
+ def npm_top_level_updater_args(top_level_dependency_updates:, flattenend_manifest_dependencies:)
247
+ top_level_dependency_updates.map do |dependency|
248
+ # NOTE: For git dependencies we loose some information about the
249
+ # requirement that's only available in the package.json, e.g. when
250
+ # specifying a semver tag:
251
+ # `dependabot/depeendabot-core#semver:^0.1` - this is required to
252
+ # pass the correct install argument to `npm install`
253
+ existing_version_requirement = flattenend_manifest_dependencies[dependency.fetch(:name)]
254
+ npm_install_args(
255
+ dependency.fetch(:name),
256
+ dependency.fetch(:version),
257
+ dependency.fetch(:requirements),
258
+ existing_version_requirement
259
+ )
260
+ end
261
+ end
262
+
263
+ def flattenend_manifest_dependencies_for_lockfile_name(lockfile_name)
264
+ package_json_content = updated_package_json_content_for_lockfile_name(lockfile_name)
265
+ return {} unless package_json_content
266
+
267
+ parsed_package = JSON.parse(package_json_content)
268
+ NpmAndYarn::FileParser::DEPENDENCY_TYPES.inject({}) do |deps, type|
269
+ deps.merge(parsed_package[type] || {})
270
+ end
271
+ end
272
+
273
+ def npm_install_args(dep_name, desired_version, requirements, existing_version_requirement)
274
+ git_requirement = requirements.find { |req| req[:source] && req[:source][:type] == "git" }
275
+
276
+ if git_requirement
277
+ existing_version_requirement ||= git_requirement[:source][:url]
278
+
279
+ # NOTE: Git is configured to auth over https while updating
280
+ existing_version_requirement = existing_version_requirement.gsub(
281
+ %r{git\+ssh://git@(.*?)[:/]}, 'https://\1/'
282
+ )
283
+
284
+ # NOTE: Keep any semver range that has already been updated by the
285
+ # PackageJsonUpdater when installing the new version
286
+ if existing_version_requirement.include?(desired_version)
287
+ "#{dep_name}@#{existing_version_requirement}"
288
+ else
289
+ "#{dep_name}@#{existing_version_requirement.sub(/#.*/, '')}##{desired_version}"
290
+ end
291
+ else
292
+ "#{dep_name}@#{desired_version}"
293
+ end
178
294
  end
179
295
 
180
296
  # rubocop:disable Metrics/AbcSize
@@ -198,7 +314,7 @@ module Dependabot
198
314
  # workspace project)
199
315
  sub_dep_local_path_error = "does not contain a package.json file"
200
316
  if error_message.match?(INVALID_PACKAGE) ||
201
- error_message.start_with?("Invalid package name") ||
317
+ error_message.include?("Invalid package name") ||
202
318
  error_message.include?(sub_dep_local_path_error)
203
319
  raise_resolvability_error(error_message, lockfile)
204
320
  end
@@ -222,7 +338,7 @@ module Dependabot
222
338
  # This happens if a new version has been published but npm is having
223
339
  # consistency issues and the version isn't fully available on all
224
340
  # queries
225
- if error_message.start_with?("No matching vers") &&
341
+ if error_message.include?("No matching vers") &&
226
342
  dependencies_in_error_message?(error_message) &&
227
343
  resolvable_before_update?(lockfile)
228
344
 
@@ -249,10 +365,8 @@ module Dependabot
249
365
  handle_missing_package(sanitized_name, sanitized_error, lockfile)
250
366
  end
251
367
 
252
- if error_message.match?(UNREACHABLE_GIT)
253
- dependency_url =
254
- error_message.match(UNREACHABLE_GIT).
255
- named_captures.fetch("url")
368
+ if (git_error = error_message.match(UNREACHABLE_GIT) || error_message.match(FORBIDDEN_GIT))
369
+ dependency_url = git_error.named_captures.fetch("url")
256
370
 
257
371
  raise Dependabot::GitDependenciesNotReachable, dependency_url
258
372
  end
@@ -270,14 +384,22 @@ module Dependabot
270
384
  lockfile)
271
385
  end
272
386
 
273
- if (error_message.start_with?("No matching vers", "404 Not Found") ||
274
- error_message.include?("not match any file(s) known to git") ||
387
+ if (error_message.include?("No matching vers") ||
388
+ error_message.include?("404 Not Found") ||
275
389
  error_message.include?("Non-registry package missing package") ||
276
- error_message.include?("Invalid tag name")) &&
390
+ error_message.include?("Invalid tag name") ||
391
+ error_message.match?(NPM6_MISSING_GIT_REF) ||
392
+ error_message.match?(NPM7_MISSING_GIT_REF)) &&
277
393
  !resolvable_before_update?(lockfile)
278
394
  raise_resolvability_error(error_message, lockfile)
279
395
  end
280
396
 
397
+ # NOTE: This check was introduced in npm7/arborist
398
+ if error_message.include?("must provide string spec")
399
+ msg = "Error parsing your package.json manifest: the version requirement must be a string"
400
+ raise Dependabot::DependencyFileNotParseable, msg
401
+ end
402
+
281
403
  raise error
282
404
  end
283
405
  # rubocop:enable Metrics/AbcSize
@@ -388,8 +510,11 @@ module Dependabot
388
510
  file.content
389
511
  end
390
512
 
391
- # When updating a package-lock.json we have to manually lock all
392
- # git dependencies, otherwise npm will (unhelpfully) update them
513
+ # TODO: Figure out if we need to lock git deps for npm 7 and can
514
+ # start deprecating this hornets nest
515
+ #
516
+ # NOTE: When updating a package-lock.json we have to manually lock
517
+ # all git dependencies, otherwise npm will (unhelpfully) update them
393
518
  updated_content = lock_git_deps(updated_content)
394
519
  updated_content = replace_ssh_sources(updated_content)
395
520
  updated_content = lock_deps_with_latest_reqs(updated_content)
@@ -502,46 +627,107 @@ module Dependabot
502
627
  @git_ssh_requirements_to_swap
503
628
  end
504
629
 
505
- def post_process_npm_lockfile(original_content, updated_content)
506
- updated_content =
507
- replace_project_metadata(updated_content, original_content)
630
+ def post_process_npm_lockfile(original_content, updated_content, lockfile_name)
631
+ updated_content = replace_project_metadata(updated_content, original_content)
508
632
 
509
633
  # Switch SSH requirements back for git dependencies
634
+ updated_content = replace_swapped_git_ssh_requirements(updated_content)
635
+
636
+ # Switch from details back for git dependencies (they will have
637
+ # changed because we locked them)
638
+ updated_content = replace_locked_git_dependencies(updated_content)
639
+
640
+ # Switch back npm 7 lockfile "pacakages" requirements from the package.json
641
+ updated_content = restore_locked_package_dependencies(lockfile_name, updated_content)
642
+
643
+ # Switch back the protocol of tarball resolutions if they've changed
644
+ # (fixes an npm bug, which appears to be applied inconsistently)
645
+ replace_tarball_urls(updated_content)
646
+ end
647
+
648
+ # NOTE: This is a workaround to "sync" what's in package.json
649
+ # requirements and the `packages.""` entry in npm 7 v2 lockfiles. These
650
+ # get out of sync because we lock git dependencies (that are not being
651
+ # updated) to a specific sha to prevent unrelated updates and the way we
652
+ # invoke the `npm install` cli, where we might tell npm to install a
653
+ # specific versionm e.g. `npm install eslint@1.1.8` but we keep the
654
+ # `package.json` requirement for eslint at `^1.0.0`, in which case we
655
+ # need to copy this from the manifest to the lockfile after the update
656
+ # has finished.
657
+ def restore_locked_package_dependencies(lockfile_name, lockfile_content)
658
+ return lockfile_content unless npm7?(lockfile_content)
659
+
660
+ original_package = updated_package_json_content_for_lockfile_name(lockfile_name)
661
+ return lockfile_content unless original_package
662
+
663
+ parsed_package = JSON.parse(original_package)
664
+ parsed_lockfile = JSON.parse(lockfile_content)
665
+ dependency_names_to_restore = (dependencies.map(&:name) + git_dependencies_to_lock.keys).uniq
666
+
667
+ NpmAndYarn::FileParser::DEPENDENCY_TYPES.each do |type|
668
+ parsed_package.fetch(type, {}).each do |dependency_name, original_requirement|
669
+ next unless dependency_names_to_restore.include?(dependency_name)
670
+
671
+ locked_requirement = parsed_lockfile.dig("packages", "", type, dependency_name)
672
+ next unless locked_requirement
673
+
674
+ locked_req = %("#{dependency_name}": "#{locked_requirement}")
675
+ original_req = %("#{dependency_name}": "#{original_requirement}")
676
+ lockfile_content = lockfile_content.gsub(locked_req, original_req)
677
+ end
678
+ end
679
+
680
+ lockfile_content
681
+ end
682
+
683
+ def replace_swapped_git_ssh_requirements(lockfile_content)
510
684
  git_ssh_requirements_to_swap.each do |req|
511
685
  new_r = req.gsub(%r{git\+ssh://git@(.*?)[:/]}, 'git+https://\1/')
512
686
  old_r = req.gsub(%r{git@(.*?)[:/]}, 'git@\1/')
513
- updated_content = updated_content.gsub(new_r, old_r)
687
+ lockfile_content = lockfile_content.gsub(new_r, old_r)
514
688
  end
515
689
 
690
+ lockfile_content
691
+ end
692
+
693
+ def replace_locked_git_dependencies(lockfile_content)
516
694
  # Switch from details back for git dependencies (they will have
517
695
  # changed because we locked them)
518
- git_dependencies_to_lock.each do |_, details|
696
+ git_dependencies_to_lock.each do |dependency_name, details|
519
697
  next unless details[:version] && details[:from]
520
698
 
521
699
  # When locking git dependencies in package.json we set the version
522
700
  # to be the git commit from the lockfile "version" field which
523
701
  # updates the lockfile "from" field to the new git commit when we
524
702
  # run npm install
525
- locked_from = %("from": "#{details[:version]}")
526
703
  original_from = %("from": "#{details[:from]}")
527
- updated_content = updated_content.gsub(locked_from, original_from)
704
+ if npm7?(lockfile_content)
705
+ # NOTE: The `from` syntax has changed in npm 7 to inclued the dependency name
706
+ npm7_locked_from = %("from": "#{dependency_name}@#{details[:version]}")
707
+ lockfile_content = lockfile_content.gsub(npm7_locked_from, original_from)
708
+ else
709
+ npm6_locked_from = %("from": "#{details[:version]}")
710
+ lockfile_content = lockfile_content.gsub(npm6_locked_from, original_from)
711
+ end
528
712
  end
529
713
 
530
- # Switch back the protocol of tarball resolutions if they've changed
531
- # (fixes an npm bug, which appears to be applied inconsistently)
714
+ lockfile_content
715
+ end
716
+
717
+ def replace_tarball_urls(lockfile_content)
532
718
  tarball_urls.each do |url|
533
719
  trimmed_url = url.gsub(/(\d+\.)*tgz$/, "")
534
720
  incorrect_url = if url.start_with?("https")
535
721
  trimmed_url.gsub(/^https:/, "http:")
536
722
  else trimmed_url.gsub(/^http:/, "https:")
537
723
  end
538
- updated_content = updated_content.gsub(
724
+ lockfile_content = lockfile_content.gsub(
539
725
  /#{Regexp.quote(incorrect_url)}(?=(\d+\.)*tgz")/,
540
726
  trimmed_url
541
727
  )
542
728
  end
543
729
 
544
- updated_content
730
+ lockfile_content
545
731
  end
546
732
 
547
733
  def replace_project_metadata(new_content, old_content)
@@ -579,6 +765,15 @@ module Dependabot
579
765
  ).npmrc_content
580
766
  end
581
767
 
768
+ def updated_package_json_content_for_lockfile_name(lockfile_name)
769
+ lockfile_basename = Pathname.new(lockfile_name).basename.to_s
770
+ package_name = lockfile_name.sub(lockfile_basename, "package.json")
771
+ package_json = package_files.find { |f| f.name == package_name }
772
+ return unless package_json
773
+
774
+ updated_package_json_content(package_json)
775
+ end
776
+
582
777
  def updated_package_json_content(file)
583
778
  @updated_package_json_content ||= {}
584
779
  @updated_package_json_content[file.name] ||=
@@ -592,6 +787,10 @@ module Dependabot
592
787
  npmrc_content.match?(/^package-lock\s*=\s*false/)
593
788
  end
594
789
 
790
+ def npm7?(lockfile_content)
791
+ Dependabot::NpmAndYarn::Helpers.npm_version(lockfile_content) == "npm7"
792
+ end
793
+
595
794
  def sanitized_package_json_content(content)
596
795
  content.
597
796
  gsub(/\{\{[^\}]*?\}\}/, "something"). # {{ nm }} syntax not allowed