dependabot-npm_and_yarn 0.91.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/helpers/build +14 -0
  3. data/helpers/npm/.eslintrc +14 -0
  4. data/helpers/npm/bin/run.js +34 -0
  5. data/helpers/npm/lib/helpers.js +25 -0
  6. data/helpers/npm/lib/peer-dependency-checker.js +102 -0
  7. data/helpers/npm/lib/subdependency-updater.js +48 -0
  8. data/helpers/npm/lib/updater.js +101 -0
  9. data/helpers/npm/package-lock.json +8868 -0
  10. data/helpers/npm/package.json +17 -0
  11. data/helpers/npm/test/fixtures/npm-left-pad.json +1 -0
  12. data/helpers/npm/test/fixtures/updater/original/package-lock.json +16 -0
  13. data/helpers/npm/test/fixtures/updater/original/package.json +9 -0
  14. data/helpers/npm/test/fixtures/updater/updated/package-lock.json +16 -0
  15. data/helpers/npm/test/helpers.js +7 -0
  16. data/helpers/npm/test/updater.test.js +50 -0
  17. data/helpers/npm/yarn.lock +6176 -0
  18. data/helpers/yarn/.eslintrc +14 -0
  19. data/helpers/yarn/bin/run.js +36 -0
  20. data/helpers/yarn/lib/fix-duplicates.js +78 -0
  21. data/helpers/yarn/lib/helpers.js +5 -0
  22. data/helpers/yarn/lib/lockfile-parser.js +21 -0
  23. data/helpers/yarn/lib/peer-dependency-checker.js +130 -0
  24. data/helpers/yarn/lib/replace-lockfile-declaration.js +57 -0
  25. data/helpers/yarn/lib/subdependency-updater.js +69 -0
  26. data/helpers/yarn/lib/updater.js +266 -0
  27. data/helpers/yarn/package.json +17 -0
  28. data/helpers/yarn/test/fixtures/updater/original/package.json +6 -0
  29. data/helpers/yarn/test/fixtures/updater/original/yarn.lock +11 -0
  30. data/helpers/yarn/test/fixtures/updater/updated/yarn.lock +12 -0
  31. data/helpers/yarn/test/fixtures/updater/with-version-comments/package.json +5 -0
  32. data/helpers/yarn/test/fixtures/updater/with-version-comments/yarn.lock +13 -0
  33. data/helpers/yarn/test/fixtures/yarnpkg-is-positive.json +1 -0
  34. data/helpers/yarn/test/fixtures/yarnpkg-left-pad.json +1 -0
  35. data/helpers/yarn/test/helpers.js +7 -0
  36. data/helpers/yarn/test/updater.test.js +93 -0
  37. data/helpers/yarn/yarn.lock +4760 -0
  38. data/lib/dependabot/npm_and_yarn/file_fetcher/path_dependency_builder.rb +146 -0
  39. data/lib/dependabot/npm_and_yarn/file_fetcher.rb +332 -0
  40. data/lib/dependabot/npm_and_yarn/file_parser.rb +397 -0
  41. data/lib/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater.rb +527 -0
  42. data/lib/dependabot/npm_and_yarn/file_updater/npmrc_builder.rb +190 -0
  43. data/lib/dependabot/npm_and_yarn/file_updater/package_json_preparer.rb +87 -0
  44. data/lib/dependabot/npm_and_yarn/file_updater/package_json_updater.rb +218 -0
  45. data/lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb +471 -0
  46. data/lib/dependabot/npm_and_yarn/file_updater.rb +189 -0
  47. data/lib/dependabot/npm_and_yarn/metadata_finder.rb +217 -0
  48. data/lib/dependabot/npm_and_yarn/native_helpers.rb +28 -0
  49. data/lib/dependabot/npm_and_yarn/requirement.rb +145 -0
  50. data/lib/dependabot/npm_and_yarn/update_checker/latest_version_finder.rb +340 -0
  51. data/lib/dependabot/npm_and_yarn/update_checker/library_detector.rb +67 -0
  52. data/lib/dependabot/npm_and_yarn/update_checker/registry_finder.rb +224 -0
  53. data/lib/dependabot/npm_and_yarn/update_checker/requirements_updater.rb +193 -0
  54. data/lib/dependabot/npm_and_yarn/update_checker/subdependency_version_resolver.rb +223 -0
  55. data/lib/dependabot/npm_and_yarn/update_checker/version_resolver.rb +495 -0
  56. data/lib/dependabot/npm_and_yarn/update_checker.rb +282 -0
  57. data/lib/dependabot/npm_and_yarn/version.rb +34 -0
  58. data/lib/dependabot/npm_and_yarn.rb +11 -0
  59. metadata +226 -0
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ # See https://docs.npmjs.com/files/package.json for package.json format docs.
4
+
5
+ require "dependabot/dependency"
6
+ require "dependabot/file_parsers"
7
+ require "dependabot/file_parsers/base"
8
+ require "dependabot/shared_helpers"
9
+ require "dependabot/npm_and_yarn/native_helpers"
10
+ require "dependabot/errors"
11
+
12
+ # rubocop:disable Metrics/ClassLength
13
+ module Dependabot
14
+ module NpmAndYarn
15
+ class FileParser < Dependabot::FileParsers::Base
16
+ require "dependabot/file_parsers/base/dependency_set"
17
+
18
+ DEPENDENCY_TYPES =
19
+ %w(dependencies devDependencies optionalDependencies).freeze
20
+ CENTRAL_REGISTRIES = %w(
21
+ https://registry.npmjs.org
22
+ http://registry.npmjs.org
23
+ https://registry.yarnpkg.com
24
+ ).freeze
25
+ GIT_URL_REGEX = %r{
26
+ (?:^|^git.*?|^github:|^bitbucket:|^gitlab:|github\.com/)
27
+ (?<username>[a-z0-9-]+)/
28
+ (?<repo>[a-z0-9_.-]+)
29
+ (
30
+ (?:\#semver:(?<semver>.+))|
31
+ (?:\#(?<ref>.+))
32
+ )?$
33
+ }ix.freeze
34
+
35
+ def parse
36
+ dependency_set = DependencySet.new
37
+ dependency_set += manifest_dependencies
38
+ dependency_set += yarn_lock_dependencies if yarn_locks.any?
39
+ dependency_set += package_lock_dependencies if package_locks.any?
40
+ dependency_set += shrinkwrap_dependencies if shrinkwraps.any?
41
+ dependencies = dependency_set.dependencies
42
+
43
+ # TODO: Currently, Dependabot can't handle dependencies that have both
44
+ # a git source *and* a non-git source. Fix that!
45
+ dependencies.reject do |dep|
46
+ dep.requirements.any? { |r| r.dig(:source, :type) == "git" } &&
47
+ dep.requirements.any? { |r| r.dig(:source, :type) != "git" }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def manifest_dependencies
54
+ dependency_set = DependencySet.new
55
+
56
+ package_files.each do |file|
57
+ # TODO: Currently, Dependabot can't handle flat dependency files
58
+ # (and will error at the FileUpdater stage, because the
59
+ # UpdateChecker doesn't take account of flat resolution).
60
+ next if JSON.parse(file.content)["flat"]
61
+
62
+ DEPENDENCY_TYPES.each do |type|
63
+ deps = JSON.parse(file.content)[type] || {}
64
+ deps.each do |name, requirement|
65
+ requirement = "*" if requirement == ""
66
+ dep = build_dependency(
67
+ file: file, type: type, name: name, requirement: requirement
68
+ )
69
+ dependency_set << dep if dep
70
+ end
71
+ end
72
+ end
73
+
74
+ dependency_set
75
+ end
76
+
77
+ def yarn_lock_dependencies
78
+ dependency_set = DependencySet.new
79
+
80
+ yarn_locks.each do |yarn_lock|
81
+ parse_yarn_lock(yarn_lock).each do |req, details|
82
+ next unless details["version"] && details["version"] != ""
83
+
84
+ # Note: The DependencySet will de-dupe our dependencies, so they
85
+ # end up unique by name. That's not a perfect representation of
86
+ # the nested nature of JS resolution, but it makes everything work
87
+ # comparably to other flat-resolution strategies
88
+ dependency_set << Dependency.new(
89
+ name: req.split(/(?<=\w)\@/).first,
90
+ version: details["version"],
91
+ package_manager: "npm_and_yarn",
92
+ requirements: []
93
+ )
94
+ end
95
+ end
96
+
97
+ dependency_set
98
+ end
99
+
100
+ def package_lock_dependencies
101
+ dependency_set = DependencySet.new
102
+
103
+ # Note: The DependencySet will de-dupe our dependencies, so they
104
+ # end up unique by name. That's not a perfect representation of
105
+ # the nested nature of JS resolution, but it makes everything work
106
+ # comparably to other flat-resolution strategies
107
+ package_locks.each do |package_lock|
108
+ parsed_lockfile = parse_package_lock(package_lock)
109
+ deps = recursively_fetch_npm_lock_dependencies(parsed_lockfile)
110
+ dependency_set += deps
111
+ end
112
+
113
+ dependency_set
114
+ end
115
+
116
+ def shrinkwrap_dependencies
117
+ dependency_set = DependencySet.new
118
+
119
+ # Note: The DependencySet will de-dupe our dependencies, so they
120
+ # end up unique by name. That's not a perfect representation of
121
+ # the nested nature of JS resolution, but it makes everything work
122
+ # comparably to other flat-resolution strategies
123
+ shrinkwraps.each do |shrinkwrap|
124
+ parsed_lockfile = parse_shrinkwrap(shrinkwrap)
125
+ deps = recursively_fetch_npm_lock_dependencies(parsed_lockfile)
126
+ dependency_set += deps
127
+ end
128
+
129
+ dependency_set
130
+ end
131
+
132
+ def recursively_fetch_npm_lock_dependencies(object_with_dependencies)
133
+ dependency_set = DependencySet.new
134
+
135
+ object_with_dependencies.
136
+ fetch("dependencies", {}).each do |name, details|
137
+ next unless details["version"] && details["version"] != ""
138
+
139
+ dependency_set << Dependency.new(
140
+ name: name,
141
+ version: details["version"],
142
+ package_manager: "npm_and_yarn",
143
+ requirements: []
144
+ )
145
+
146
+ dependency_set += recursively_fetch_npm_lock_dependencies(details)
147
+ end
148
+
149
+ dependency_set
150
+ end
151
+
152
+ def build_dependency(file:, type:, name:, requirement:)
153
+ return if lockfile_details(name, requirement) &&
154
+ !version_for(name, requirement)
155
+ return if ignore_requirement?(requirement)
156
+ return if workspace_package_names.include?(name)
157
+
158
+ Dependency.new(
159
+ name: name,
160
+ version: version_for(name, requirement),
161
+ package_manager: "npm_and_yarn",
162
+ requirements: [{
163
+ requirement: requirement_for(requirement),
164
+ file: file.name,
165
+ groups: [type],
166
+ source: source_for(name, requirement)
167
+ }]
168
+ )
169
+ end
170
+
171
+ def check_required_files
172
+ raise "No package.json!" unless get_original_file("package.json")
173
+ end
174
+
175
+ def ignore_requirement?(requirement)
176
+ return true if local_path?(requirement)
177
+ return true if non_git_url?(requirement)
178
+
179
+ # TODO: Handle aliased packages
180
+ alias_package?(requirement)
181
+ end
182
+
183
+ def local_path?(requirement)
184
+ requirement.start_with?("link:", "file:", "/", "./", "../", "~/")
185
+ end
186
+
187
+ def alias_package?(requirement)
188
+ requirement.start_with?("npm:")
189
+ end
190
+
191
+ def non_git_url?(requirement)
192
+ requirement.include?("://") && !git_url?(requirement)
193
+ end
194
+
195
+ def git_url?(requirement)
196
+ requirement.match?(GIT_URL_REGEX)
197
+ end
198
+
199
+ def workspace_package_names
200
+ @workspace_package_names ||=
201
+ package_files.map { |f| JSON.parse(f.content)["name"] }.compact
202
+ end
203
+
204
+ # rubocop:disable Metrics/CyclomaticComplexity
205
+ # rubocop:disable Metrics/PerceivedComplexity
206
+ def version_for(name, requirement)
207
+ lock_version = lockfile_details(name, requirement)&.
208
+ fetch("version", nil)
209
+ lock_res = lockfile_details(name, requirement)&.
210
+ fetch("resolved", nil)
211
+
212
+ if git_url?(requirement)
213
+ return lock_version.split("#").last if lock_version&.include?("#")
214
+ return lock_res.split("#").last if lock_res&.include?("#")
215
+
216
+ if lock_res && lock_res.split("/").last.match?(/^[0-9a-f]{40}$/)
217
+ return lock_res.split("/").last
218
+ end
219
+
220
+ return nil
221
+ end
222
+
223
+ return unless lock_version
224
+ return if lock_version.include?("://")
225
+ return if lock_version.include?("file:")
226
+ return if lock_version.include?("link:")
227
+ return if lock_version.include?("#")
228
+
229
+ lock_version
230
+ end
231
+ # rubocop:enable Metrics/CyclomaticComplexity
232
+ # rubocop:enable Metrics/PerceivedComplexity
233
+
234
+ def source_for(name, requirement)
235
+ return git_source_for(requirement) if git_url?(requirement)
236
+
237
+ resolved_url = lockfile_details(name, requirement)&.
238
+ fetch("resolved", nil)
239
+
240
+ return unless resolved_url
241
+ return unless resolved_url.start_with?("http")
242
+ return if CENTRAL_REGISTRIES.any? { |u| resolved_url.start_with?(u) }
243
+ return if resolved_url.include?("github")
244
+
245
+ private_registry_source_for(resolved_url, name)
246
+ end
247
+
248
+ def requirement_for(requirement)
249
+ return requirement unless git_url?(requirement)
250
+
251
+ details = requirement.match(GIT_URL_REGEX).named_captures
252
+ details["semver"]
253
+ end
254
+
255
+ def git_source_for(requirement)
256
+ details = requirement.match(GIT_URL_REGEX).named_captures
257
+ {
258
+ type: "git",
259
+ url: "https://github.com/#{details['username']}/#{details['repo']}",
260
+ branch: nil,
261
+ ref: details["ref"] || "master"
262
+ }
263
+ end
264
+
265
+ def private_registry_source_for(resolved_url, name)
266
+ url =
267
+ if resolved_url.include?("/~/")
268
+ # Gemfury format
269
+ resolved_url.split("/~/").first
270
+ elsif resolved_url.include?("/#{name}/-/#{name}")
271
+ # Sonatype Nexus / Artifactory JFrog format
272
+ resolved_url.split("/#{name}/-/#{name}").first
273
+ elsif (cred_url = credential_url(resolved_url)) then cred_url
274
+ else resolved_url.split("/")[0..2].join("/")
275
+ end
276
+
277
+ { type: "private_registry", url: url }
278
+ end
279
+
280
+ def credential_url(resolved_url)
281
+ registries = credentials.
282
+ select { |cred| cred["type"] == "npm_registry" }
283
+
284
+ registries.each do |details|
285
+ reg = details["registry"]
286
+ next unless resolved_url.include?(reg)
287
+
288
+ return resolved_url.gsub(/#{Regexp.quote(reg)}.*/, "") + reg
289
+ end
290
+
291
+ false
292
+ end
293
+
294
+ def lockfile_details(name, requirement)
295
+ [*package_locks, *shrinkwraps].each do |package_lock|
296
+ parsed_package_lock_json = parse_package_lock(package_lock)
297
+ next unless parsed_package_lock_json.dig("dependencies", name)
298
+
299
+ return parsed_package_lock_json.dig("dependencies", name)
300
+ end
301
+
302
+ req = requirement
303
+ yarn_locks.each do |yarn_lock|
304
+ parsed_yarn_lock = parse_yarn_lock(yarn_lock)
305
+
306
+ details_candidates =
307
+ parsed_yarn_lock.
308
+ select { |k, _| k.split(/(?<=\w)\@/).first == name }
309
+
310
+ # If there's only one entry for this dependency, use it, even if
311
+ # the requirement in the lockfile doesn't match
312
+ details = details_candidates.first.last if details_candidates.one?
313
+
314
+ details ||=
315
+ details_candidates.
316
+ find { |k, _| k.split(/(?<=\w)\@/)[1..-1].join("@") == req }&.
317
+ last
318
+
319
+ return details if details
320
+ end
321
+
322
+ nil
323
+ end
324
+
325
+ def parse_package_lock(package_lock)
326
+ JSON.parse(package_lock.content)
327
+ rescue JSON::ParserError
328
+ raise Dependabot::DependencyFileNotParseable, package_lock.path
329
+ end
330
+
331
+ def parse_shrinkwrap(shrinkwrap)
332
+ JSON.parse(shrinkwrap.content)
333
+ rescue JSON::ParserError
334
+ raise Dependabot::DependencyFileNotParseable, shrinkwrap.path
335
+ end
336
+
337
+ def parse_yarn_lock(yarn_lock)
338
+ @parsed_yarn_lock ||= {}
339
+ @parsed_yarn_lock[yarn_lock.name] ||=
340
+ SharedHelpers.in_a_temporary_directory do
341
+ File.write("yarn.lock", yarn_lock.content)
342
+
343
+ SharedHelpers.run_helper_subprocess(
344
+ command: "node #{yarn_helper_path}",
345
+ function: "parseLockfile",
346
+ args: [Dir.pwd]
347
+ )
348
+ rescue SharedHelpers::HelperSubprocessFailed
349
+ raise Dependabot::DependencyFileNotParseable, yarn_lock.path
350
+ end
351
+ end
352
+
353
+ def yarn_helper_path
354
+ NativeHelpers.yarn_helper_path
355
+ end
356
+
357
+ def package_files
358
+ sub_packages =
359
+ dependency_files.
360
+ select { |f| f.name.end_with?("package.json") }.
361
+ reject { |f| f.name == "package.json" }.
362
+ reject(&:support_file?)
363
+
364
+ [
365
+ dependency_files.find { |f| f.name == "package.json" },
366
+ *sub_packages
367
+ ].compact
368
+ end
369
+
370
+ def lockfile?
371
+ package_locks.any? || yarn_locks.any?
372
+ end
373
+
374
+ def package_locks
375
+ @package_locks ||=
376
+ dependency_files.
377
+ select { |f| f.name.end_with?("package-lock.json") }
378
+ end
379
+
380
+ def yarn_locks
381
+ @yarn_locks ||=
382
+ dependency_files.
383
+ select { |f| f.name.end_with?("yarn.lock") }
384
+ end
385
+
386
+ def shrinkwraps
387
+ @shrinkwraps ||=
388
+ dependency_files.
389
+ select { |f| f.name.end_with?("npm-shrinkwrap.json") }
390
+ end
391
+ end
392
+ end
393
+ end
394
+ # rubocop:enable Metrics/ClassLength
395
+
396
+ Dependabot::FileParsers.
397
+ register("npm_and_yarn", Dependabot::NpmAndYarn::FileParser)
@@ -0,0 +1,527 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/npm_and_yarn/file_updater"
4
+ require "dependabot/npm_and_yarn/file_parser"
5
+ require "dependabot/npm_and_yarn/update_checker/registry_finder"
6
+ require "dependabot/npm_and_yarn/native_helpers"
7
+ require "dependabot/shared_helpers"
8
+ require "dependabot/errors"
9
+
10
+ # rubocop:disable Metrics/ClassLength
11
+ module Dependabot
12
+ module NpmAndYarn
13
+ class FileUpdater
14
+ class NpmLockfileUpdater
15
+ require_relative "npmrc_builder"
16
+ require_relative "package_json_updater"
17
+
18
+ def initialize(dependencies:, dependency_files:, credentials:)
19
+ @dependencies = dependencies
20
+ @dependency_files = dependency_files
21
+ @credentials = credentials
22
+ end
23
+
24
+ def updated_lockfile_content(lockfile)
25
+ return lockfile.content if npmrc_disables_lockfile?
26
+ return lockfile.content if updatable_dependencies(lockfile).empty?
27
+
28
+ @updated_lockfile_content ||= {}
29
+ @updated_lockfile_content[lockfile.name] ||=
30
+ SharedHelpers.in_a_temporary_directory do
31
+ path = Pathname.new(lockfile.name).dirname.to_s
32
+ lockfile_name = Pathname.new(lockfile.name).basename.to_s
33
+ write_temporary_dependency_files(lockfile.name)
34
+ updated_files = Dir.chdir(path) do
35
+ run_current_npm_update(lockfile_name: lockfile_name)
36
+ end
37
+ updated_content = updated_files.fetch(lockfile_name)
38
+ post_process_npm_lockfile(updated_content)
39
+ end
40
+ rescue SharedHelpers::HelperSubprocessFailed => error
41
+ handle_npm_updater_error(error, lockfile)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :dependencies, :dependency_files, :credentials
47
+
48
+ UNREACHABLE_GIT =
49
+ /ls-remote (?:(-h -t)|(--tags --heads)) (?<url>.*)/.freeze
50
+ FORBIDDEN_PACKAGE =
51
+ /(403 Forbidden|401 Unauthorized): (?<package_req>.*)/.freeze
52
+ MISSING_PACKAGE = /404 Not Found: (?<package_req>.*)/.freeze
53
+ INVALID_PACKAGE = /Can't install (?<package_req>.*): Missing/.freeze
54
+
55
+ def top_level_dependencies
56
+ dependencies.select(&:top_level?)
57
+ end
58
+
59
+ def sub_dependencies
60
+ dependencies.reject(&:top_level?)
61
+ end
62
+
63
+ def updatable_dependencies(lockfile)
64
+ lockfile_dir = Pathname.new(lockfile.name).dirname.to_s
65
+ dependencies.reject do |dependency|
66
+ dependency_up_to_date?(lockfile, dependency) ||
67
+ top_level_dependency_update_not_required?(dependency,
68
+ lockfile_dir)
69
+ end
70
+ end
71
+
72
+ def dependency_up_to_date?(lockfile, dependency)
73
+ existing_dep = NpmAndYarn::FileParser.new(
74
+ dependency_files: [lockfile, *package_files],
75
+ source: nil,
76
+ credentials: credentials
77
+ ).parse.find { |dep| dep.name == dependency.name }
78
+
79
+ # If the dependency is missing but top level it should be treated as
80
+ # not up to date
81
+ # If it's a missing sub dependency we treat it as up to date
82
+ # (likely it is no longer required)
83
+ return !dependency.top_level? if existing_dep.nil?
84
+
85
+ existing_dep&.version == dependency.version
86
+ end
87
+
88
+ # Prevent changes to the lockfile when the dependency has been
89
+ # required in a package.json outside the current folder (e.g. lerna
90
+ # proj)
91
+ def top_level_dependency_update_not_required?(dependency,
92
+ lockfile_dir)
93
+ requirements_for_path = dependency.requirements.select do |req|
94
+ req_dir = Pathname.new(req[:file]).dirname.to_s
95
+ req_dir == lockfile_dir
96
+ end
97
+
98
+ dependency.top_level? && requirements_for_path.empty?
99
+ end
100
+
101
+ def run_current_npm_update(lockfile_name:)
102
+ top_level_dependency_updates = top_level_dependencies.map do |d|
103
+ { name: d.name, version: d.version, requirements: d.requirements }
104
+ end
105
+
106
+ run_npm_updater(
107
+ lockfile_name: lockfile_name,
108
+ top_level_dependency_updates: top_level_dependency_updates
109
+ )
110
+ end
111
+
112
+ def run_previous_npm_update(lockfile_name:)
113
+ previous_top_level_dependencies = top_level_dependencies.map do |d|
114
+ {
115
+ name: d.name,
116
+ version: d.previous_version,
117
+ requirements: d.previous_requirements
118
+ }
119
+ end
120
+
121
+ run_npm_updater(
122
+ lockfile_name: lockfile_name,
123
+ top_level_dependency_updates: previous_top_level_dependencies
124
+ )
125
+ end
126
+
127
+ def run_npm_updater(lockfile_name:, top_level_dependency_updates:)
128
+ SharedHelpers.with_git_configured(credentials: credentials) do
129
+ if top_level_dependency_updates.any?
130
+ run_npm_top_level_updater(
131
+ lockfile_name: lockfile_name,
132
+ top_level_dependency_updates: top_level_dependency_updates
133
+ )
134
+ else
135
+ run_npm_subdependency_updater(lockfile_name: lockfile_name)
136
+ end
137
+ end
138
+ end
139
+
140
+ def run_npm_top_level_updater(lockfile_name:,
141
+ top_level_dependency_updates:)
142
+ SharedHelpers.run_helper_subprocess(
143
+ command: "node #{npm_helper_path}",
144
+ function: "update",
145
+ args: [
146
+ Dir.pwd,
147
+ top_level_dependency_updates,
148
+ lockfile_name
149
+ ]
150
+ )
151
+ end
152
+
153
+ def run_npm_subdependency_updater(lockfile_name:)
154
+ SharedHelpers.run_helper_subprocess(
155
+ command: "node #{npm_helper_path}",
156
+ function: "updateSubdependency",
157
+ args: [Dir.pwd, lockfile_name]
158
+ )
159
+ end
160
+
161
+ # rubocop:disable Metrics/AbcSize
162
+ # rubocop:disable Metrics/CyclomaticComplexity
163
+ # rubocop:disable Metrics/PerceivedComplexity
164
+ # rubocop:disable Metrics/MethodLength
165
+ def handle_npm_updater_error(error, lockfile)
166
+ if error.message.match?(MISSING_PACKAGE)
167
+ package_name =
168
+ error.message.match(MISSING_PACKAGE).
169
+ named_captures["package_req"].
170
+ split(/(?<=\w)\@/).first
171
+ handle_missing_package(package_name, error, lockfile)
172
+ end
173
+ names = dependencies.map(&:name)
174
+ if names.any? { |name| error.message.include?("#{name}@") } &&
175
+ error.message.start_with?("No matching vers") &&
176
+ resolvable_before_update?(lockfile)
177
+ # This happens if a new version has been published that relies on
178
+ # but npm is having consistency issues. We raise a bespoke error
179
+ # so we can capture and ignore it if we're trying to create a new
180
+ # PR (which will be created successfully at a later date).
181
+ raise Dependabot::InconsistentRegistryResponse, error.message
182
+ end
183
+
184
+ # When the package.json doesn't include a name or version, or name
185
+ # has non url-friendly characters
186
+ if error.message.match?(INVALID_PACKAGE) ||
187
+ error.message.start_with?("Invalid package name")
188
+ raise_resolvability_error(error, lockfile)
189
+ end
190
+
191
+ if error.message.start_with?("No matching vers", "404 Not Found") ||
192
+ error.message.include?("not match any file(s) known to git") ||
193
+ error.message.include?("Non-registry package missing package") ||
194
+ error.message.include?("Cannot read property 'match' of ") ||
195
+ error.message.include?("Invalid tag name")
196
+ # This happens if a new version has been published that relies on
197
+ # subdependencies that have not yet been published.
198
+ raise if resolvable_before_update?(lockfile)
199
+
200
+ raise_resolvability_error(error, lockfile)
201
+ end
202
+ if error.message.match?(FORBIDDEN_PACKAGE)
203
+ package_name =
204
+ error.message.match(FORBIDDEN_PACKAGE).
205
+ named_captures["package_req"].
206
+ split(/(?<=\w)\@/).first
207
+ handle_missing_package(package_name, error, lockfile)
208
+ end
209
+ if error.message.match?(UNREACHABLE_GIT)
210
+ dependency_url =
211
+ error.message.match(UNREACHABLE_GIT).
212
+ named_captures.fetch("url")
213
+
214
+ raise Dependabot::GitDependenciesNotReachable, dependency_url
215
+ end
216
+ raise
217
+ end
218
+ # rubocop:enable Metrics/AbcSize
219
+ # rubocop:enable Metrics/CyclomaticComplexity
220
+ # rubocop:enable Metrics/PerceivedComplexity
221
+ # rubocop:enable Metrics/MethodLength
222
+
223
+ def raise_resolvability_error(error, lockfile)
224
+ dependency_names = dependencies.map(&:name).join(", ")
225
+ msg = "Error whilst updating #{dependency_names} in "\
226
+ "#{lockfile.path}:\n#{error.message}"
227
+ raise Dependabot::DependencyFileNotResolvable, msg
228
+ end
229
+
230
+ def handle_missing_package(package_name, error, lockfile)
231
+ missing_dep = NpmAndYarn::FileParser.new(
232
+ dependency_files: dependency_files,
233
+ source: nil,
234
+ credentials: credentials
235
+ ).parse.find { |dep| dep.name == package_name }
236
+
237
+ raise_resolvability_error(error, lockfile) unless missing_dep
238
+
239
+ reg = NpmAndYarn::UpdateChecker::RegistryFinder.new(
240
+ dependency: missing_dep,
241
+ credentials: credentials,
242
+ npmrc_file: dependency_files.
243
+ find { |f| f.name.end_with?(".npmrc") },
244
+ yarnrc_file: dependency_files.
245
+ find { |f| f.name.end_with?(".yarnrc") }
246
+ ).registry
247
+
248
+ return if central_registry?(reg) && !package_name.start_with?("@")
249
+
250
+ raise Dependabot::PrivateSourceAuthenticationFailure, reg
251
+ end
252
+
253
+ def central_registry?(registry)
254
+ NpmAndYarn::FileParser::CENTRAL_REGISTRIES.any? do |r|
255
+ r.include?(registry)
256
+ end
257
+ end
258
+
259
+ def resolvable_before_update?(lockfile)
260
+ @resolvable_before_update ||= {}
261
+ if @resolvable_before_update.key?(lockfile.name)
262
+ return @resolvable_before_update[lockfile.name]
263
+ end
264
+
265
+ @resolvable_before_update[lockfile.name] =
266
+ begin
267
+ SharedHelpers.in_a_temporary_directory do
268
+ write_temporary_dependency_files(
269
+ lockfile.name,
270
+ update_package_json: false
271
+ )
272
+
273
+ lockfile_name = Pathname.new(lockfile.name).basename.to_s
274
+ path = Pathname.new(lockfile.name).dirname.to_s
275
+ Dir.chdir(path) do
276
+ run_previous_npm_update(lockfile_name: lockfile_name)
277
+ end
278
+ end
279
+
280
+ true
281
+ rescue SharedHelpers::HelperSubprocessFailed
282
+ false
283
+ end
284
+ end
285
+
286
+ def write_temporary_dependency_files(lockfile_name,
287
+ update_package_json: true)
288
+ write_lockfiles(lockfile_name)
289
+ File.write(".npmrc", npmrc_content)
290
+
291
+ package_files.each do |file|
292
+ path = file.name
293
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
294
+
295
+ updated_content =
296
+ if update_package_json && top_level_dependencies.any?
297
+ updated_package_json_content(file)
298
+ else
299
+ file.content
300
+ end
301
+
302
+ # When updating a package-lock.json we have to manually lock all
303
+ # git dependencies, otherwise npm will (unhelpfully) update them
304
+ updated_content = lock_git_deps(updated_content)
305
+ updated_content = replace_ssh_sources(updated_content)
306
+
307
+ updated_content = sanitized_package_json_content(updated_content)
308
+ File.write(file.name, updated_content)
309
+ end
310
+ end
311
+
312
+ def write_lockfiles(lockfile_name)
313
+ excluded_lock =
314
+ case lockfile_name
315
+ when "package-lock.json" then "npm-shrinkwrap.json"
316
+ when "npm-shrinkwrap.json" then "package-lock.json"
317
+ end
318
+ [*package_locks, *shrinkwraps].each do |f|
319
+ next if f.name == excluded_lock
320
+
321
+ FileUtils.mkdir_p(Pathname.new(f.name).dirname)
322
+
323
+ if top_level_dependencies.any?
324
+ File.write(f.name, f.content)
325
+ else
326
+ File.write(f.name, prepared_npm_lockfile_content(f.content))
327
+ end
328
+ end
329
+ end
330
+
331
+ def lock_git_deps(content)
332
+ return content if git_dependencies_to_lock.empty?
333
+
334
+ types = NpmAndYarn::FileParser::DEPENDENCY_TYPES
335
+
336
+ json = JSON.parse(content)
337
+ types.each do |type|
338
+ json.fetch(type, {}).each do |nm, _|
339
+ updated_version = git_dependencies_to_lock.dig(nm, :version)
340
+ next unless updated_version
341
+
342
+ json[type][nm] = git_dependencies_to_lock[nm][:version]
343
+ end
344
+ end
345
+
346
+ json.to_json
347
+ end
348
+
349
+ def git_dependencies_to_lock
350
+ return {} unless package_locks.any?
351
+ return @git_dependencies_to_lock if @git_dependencies_to_lock
352
+
353
+ @git_dependencies_to_lock = {}
354
+ dependency_names = dependencies.map(&:name)
355
+
356
+ package_locks.each do |package_lock|
357
+ parsed_lockfile = JSON.parse(package_lock.content)
358
+ parsed_lockfile.fetch("dependencies", {}).each do |nm, details|
359
+ next if dependency_names.include?(nm)
360
+ next unless details["version"]
361
+ next unless details["version"].start_with?("git")
362
+
363
+ @git_dependencies_to_lock[nm] = {
364
+ version: details["version"],
365
+ from: details["from"]
366
+ }
367
+ end
368
+ end
369
+ @git_dependencies_to_lock
370
+ end
371
+
372
+ def replace_ssh_sources(content)
373
+ updated_content = content
374
+
375
+ git_ssh_requirements_to_swap.each do |req|
376
+ new_req = req.gsub(%r{git\+ssh://git@(.*?)[:/]}, 'https://\1/')
377
+ updated_content = updated_content.gsub(req, new_req)
378
+ end
379
+
380
+ updated_content
381
+ end
382
+
383
+ def git_ssh_requirements_to_swap
384
+ return @git_ssh_requirements_to_swap if @git_ssh_requirements_to_swap
385
+
386
+ @git_ssh_requirements_to_swap = []
387
+
388
+ package_files.each do |file|
389
+ NpmAndYarn::FileParser::DEPENDENCY_TYPES.each do |t|
390
+ JSON.parse(file.content).fetch(t, {}).each do |_, requirement|
391
+ next unless requirement.start_with?("git+ssh:")
392
+
393
+ req = requirement.split("#").first
394
+ @git_ssh_requirements_to_swap << req
395
+ end
396
+ end
397
+ end
398
+
399
+ @git_ssh_requirements_to_swap
400
+ end
401
+
402
+ def prepared_npm_lockfile_content(content)
403
+ JSON.dump(
404
+ remove_dependency_from_npm_lockfile(JSON.parse(content))
405
+ )
406
+ end
407
+
408
+ # Duplicated in SubdependencyVersionResolver
409
+ # Remove the dependency we want to update from the lockfile and let
410
+ # npm find the latest resolvable version and fix the lockfile
411
+ def remove_dependency_from_npm_lockfile(npm_lockfile)
412
+ return npm_lockfile unless npm_lockfile.key?("dependencies")
413
+
414
+ sub_dependency_names = sub_dependencies.map(&:name)
415
+ dependencies =
416
+ npm_lockfile["dependencies"].
417
+ reject { |key, _| sub_dependency_names.include?(key) }.
418
+ map { |k, v| [k, remove_dependency_from_npm_lockfile(v)] }.
419
+ to_h
420
+ npm_lockfile.merge("dependencies" => dependencies)
421
+ end
422
+
423
+ def post_process_npm_lockfile(lockfile_content)
424
+ updated_content = lockfile_content
425
+
426
+ # Switch SSH requirements back for git dependencies
427
+ git_ssh_requirements_to_swap.each do |req|
428
+ new_r = req.gsub(%r{git\+ssh://git@(.*?)[:/]}, 'git+https://\1/')
429
+ old_r = req.gsub(%r{git@(.*?)[:/]}, 'git@\1/')
430
+ updated_content = updated_content.gsub(new_r, old_r)
431
+ end
432
+
433
+ # Switch from details back for git dependencies (they will have
434
+ # changed because we locked them)
435
+ git_dependencies_to_lock.each do |_, details|
436
+ next unless details[:from]
437
+
438
+ new_r = /"from": "#{Regexp.quote(details[:from])}#[^\"]+"/
439
+ old_r = %("from": "#{details[:from]}")
440
+ updated_content = updated_content.gsub(new_r, old_r)
441
+ end
442
+
443
+ # Switch back the protocol of tarball resolutions if they've changed
444
+ # (fixes an npm bug, which appears to be applied inconsistently)
445
+ tarball_urls.each do |url|
446
+ trimmed_url = url.gsub(/(\d+\.)*tgz$/, "")
447
+ incorrect_url = if url.start_with?("https")
448
+ trimmed_url.gsub(/^https:/, "http:")
449
+ else trimmed_url.gsub(/^http:/, "https:")
450
+ end
451
+ updated_content = updated_content.gsub(
452
+ /#{Regexp.quote(incorrect_url)}(?=(\d+\.)*tgz")/,
453
+ trimmed_url
454
+ )
455
+ end
456
+
457
+ updated_content
458
+ end
459
+
460
+ def tarball_urls
461
+ all_urls = [*package_locks, *shrinkwraps].flat_map do |file|
462
+ file.content.scan(/"resolved":\s+"(.*)\"/).flatten
463
+ end
464
+ all_urls.uniq! { |url| url.gsub(/(\d+\.)*tgz$/, "") }
465
+
466
+ # If both the http:// and https:// versions of the tarball appear
467
+ # in the lockfile, prefer the https:// one
468
+ trimmed_urls = all_urls.map { |url| url.gsub(/(\d+\.)*tgz$/, "") }
469
+ all_urls.reject do |url|
470
+ next false unless url.start_with?("http:")
471
+
472
+ trimmed_url = url.gsub(/(\d+\.)*tgz$/, "")
473
+ trimmed_urls.include?(trimmed_url.gsub(/^http:/, "https:"))
474
+ end
475
+ end
476
+
477
+ def npmrc_content
478
+ NpmrcBuilder.new(
479
+ credentials: credentials,
480
+ dependency_files: dependency_files
481
+ ).npmrc_content
482
+ end
483
+
484
+ def updated_package_json_content(file)
485
+ @updated_package_json_content ||= {}
486
+ @updated_package_json_content[file.name] ||=
487
+ PackageJsonUpdater.new(
488
+ package_json: file,
489
+ dependencies: top_level_dependencies
490
+ ).updated_package_json.content
491
+ end
492
+
493
+ def npmrc_disables_lockfile?
494
+ npmrc_content.match?(/^package-lock\s*=\s*false/)
495
+ end
496
+
497
+ def sanitized_package_json_content(content)
498
+ content.
499
+ gsub(/\{\{.*?\}\}/, "something"). # {{ name }} syntax not allowed
500
+ gsub(/(?<!\\)\\ /, " "). # escaped whitespace not allowed
501
+ gsub(%r{^\s*//.*}, " ") # comments are not allowed
502
+ end
503
+
504
+ def npm_helper_path
505
+ NativeHelpers.npm_helper_path
506
+ end
507
+
508
+ def package_locks
509
+ @package_locks ||=
510
+ dependency_files.
511
+ select { |f| f.name.end_with?("package-lock.json") }
512
+ end
513
+
514
+ def shrinkwraps
515
+ @shrinkwraps ||=
516
+ dependency_files.
517
+ select { |f| f.name.end_with?("npm-shrinkwrap.json") }
518
+ end
519
+
520
+ def package_files
521
+ dependency_files.select { |f| f.name.end_with?("package.json") }
522
+ end
523
+ end
524
+ end
525
+ end
526
+ end
527
+ # rubocop:enable Metrics/ClassLength