dependabot-bun 0.296.0 → 0.296.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dependabot/bun.rb +12 -2
  3. data/lib/dependabot/javascript/bun/file_fetcher.rb +77 -0
  4. data/lib/dependabot/javascript/bun/file_parser/bun_lock.rb +156 -0
  5. data/lib/dependabot/javascript/bun/file_parser/lockfile_parser.rb +55 -0
  6. data/lib/dependabot/javascript/bun/file_parser.rb +74 -0
  7. data/lib/dependabot/javascript/bun/file_updater/lockfile_updater.rb +138 -0
  8. data/lib/dependabot/javascript/bun/file_updater.rb +75 -0
  9. data/lib/dependabot/javascript/bun/helpers.rb +72 -0
  10. data/lib/dependabot/javascript/bun/package_manager.rb +48 -0
  11. data/lib/dependabot/javascript/bun/requirement.rb +11 -0
  12. data/lib/dependabot/javascript/bun/update_checker/conflicting_dependency_resolver.rb +64 -0
  13. data/lib/dependabot/javascript/bun/update_checker/dependency_files_builder.rb +47 -0
  14. data/lib/dependabot/javascript/bun/update_checker/latest_version_finder.rb +450 -0
  15. data/lib/dependabot/javascript/bun/update_checker/library_detector.rb +76 -0
  16. data/lib/dependabot/javascript/bun/update_checker/requirements_updater.rb +203 -0
  17. data/lib/dependabot/javascript/bun/update_checker/subdependency_version_resolver.rb +144 -0
  18. data/lib/dependabot/javascript/bun/update_checker/version_resolver.rb +525 -0
  19. data/lib/dependabot/javascript/bun/update_checker/vulnerability_auditor.rb +165 -0
  20. data/lib/dependabot/javascript/bun/update_checker.rb +440 -0
  21. data/lib/dependabot/javascript/bun/version.rb +11 -0
  22. data/lib/dependabot/javascript/shared/constraint_helper.rb +359 -0
  23. data/lib/dependabot/javascript/shared/dependency_files_filterer.rb +164 -0
  24. data/lib/dependabot/javascript/shared/file_fetcher.rb +283 -0
  25. data/lib/dependabot/javascript/shared/file_parser/lockfile_parser.rb +106 -0
  26. data/lib/dependabot/javascript/shared/file_parser.rb +454 -0
  27. data/lib/dependabot/javascript/shared/file_updater/npmrc_builder.rb +394 -0
  28. data/lib/dependabot/javascript/shared/file_updater/package_json_preparer.rb +87 -0
  29. data/lib/dependabot/javascript/shared/file_updater/package_json_updater.rb +376 -0
  30. data/lib/dependabot/javascript/shared/file_updater.rb +179 -0
  31. data/lib/dependabot/javascript/shared/language.rb +45 -0
  32. data/lib/dependabot/javascript/shared/metadata_finder.rb +209 -0
  33. data/lib/dependabot/javascript/shared/native_helpers.rb +21 -0
  34. data/lib/dependabot/javascript/shared/package_manager_detector.rb +72 -0
  35. data/lib/dependabot/javascript/shared/package_name.rb +118 -0
  36. data/lib/dependabot/javascript/shared/registry_helper.rb +190 -0
  37. data/lib/dependabot/javascript/shared/registry_parser.rb +93 -0
  38. data/lib/dependabot/javascript/shared/requirement.rb +144 -0
  39. data/lib/dependabot/javascript/shared/sub_dependency_files_filterer.rb +79 -0
  40. data/lib/dependabot/javascript/shared/update_checker/dependency_files_builder.rb +87 -0
  41. data/lib/dependabot/javascript/shared/update_checker/registry_finder.rb +358 -0
  42. data/lib/dependabot/javascript/shared/version.rb +133 -0
  43. data/lib/dependabot/javascript/shared/version_selector.rb +60 -0
  44. data/lib/dependabot/javascript.rb +31 -0
  45. metadata +48 -17
  46. data/lib/dependabot/bun/file_fetcher.rb +0 -97
  47. data/lib/dependabot/bun/file_parser/bun_lock.rb +0 -148
  48. data/lib/dependabot/bun/helpers.rb +0 -79
  49. data/lib/dependabot/bun/language.rb +0 -45
  50. data/lib/dependabot/bun/package_manager.rb +0 -46
  51. data/lib/dependabot/bun/requirement.rb +0 -14
  52. data/lib/dependabot/bun/version.rb +0 -12
  53. data/lib/dependabot/javascript/file_fetcher_helper.rb +0 -245
  54. data/lib/dependabot/javascript/requirement.rb +0 -141
  55. data/lib/dependabot/javascript/version.rb +0 -135
@@ -0,0 +1,525 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Dependabot
5
+ module Javascript
6
+ module Bun
7
+ class UpdateChecker
8
+ class VersionResolver # rubocop:disable Metrics/ClassLength
9
+ extend T::Sig
10
+
11
+ require_relative "latest_version_finder"
12
+
13
+ TIGHTLY_COUPLED_MONOREPOS = {
14
+ "vue" => %w(vue vue-template-compiler)
15
+ }.freeze
16
+
17
+ def initialize(dependency:, credentials:, dependency_files:,
18
+ latest_allowable_version:, latest_version_finder:, repo_contents_path:, dependency_group: nil)
19
+ @dependency = dependency
20
+ @credentials = credentials
21
+ @dependency_files = dependency_files
22
+ @latest_allowable_version = latest_allowable_version
23
+ @dependency_group = dependency_group
24
+
25
+ @latest_version_finder = {}
26
+ @latest_version_finder[dependency] = latest_version_finder
27
+ @repo_contents_path = repo_contents_path
28
+ end
29
+
30
+ def latest_resolvable_version
31
+ return latest_allowable_version if git_dependency?(dependency)
32
+ return if part_of_tightly_locked_monorepo?
33
+ return if types_update_available?
34
+ return if original_package_update_available?
35
+
36
+ return latest_allowable_version unless relevant_unmet_peer_dependencies.any?
37
+
38
+ satisfying_versions.first
39
+ end
40
+
41
+ def latest_version_resolvable_with_full_unlock?
42
+ return false if dependency_updates_from_full_unlock.nil?
43
+
44
+ true
45
+ end
46
+
47
+ def latest_resolvable_previous_version(updated_version)
48
+ resolve_latest_previous_version(dependency, updated_version)
49
+ end
50
+
51
+ # rubocop:disable Metrics/PerceivedComplexity
52
+ def dependency_updates_from_full_unlock
53
+ return if git_dependency?(dependency)
54
+ return updated_monorepo_dependencies if part_of_tightly_locked_monorepo?
55
+ return if newly_broken_peer_reqs_from_dep.any?
56
+ return if original_package_update_available?
57
+
58
+ updates = [{
59
+ dependency: dependency,
60
+ version: latest_allowable_version,
61
+ previous_version: latest_resolvable_previous_version(
62
+ latest_allowable_version
63
+ )
64
+ }]
65
+ newly_broken_peer_reqs_on_dep.each do |peer_req|
66
+ dep_name = peer_req.fetch(:requiring_dep_name)
67
+ dep = top_level_dependencies.find { |d| d.name == dep_name }
68
+
69
+ # Can't handle reqs from sub-deps or git source deps (yet)
70
+ return nil if dep.nil?
71
+ return nil if git_dependency?(dep)
72
+
73
+ updated_version =
74
+ latest_version_of_dep_with_satisfied_peer_reqs(dep)
75
+ return nil unless updated_version
76
+
77
+ updates << {
78
+ dependency: dep,
79
+ version: updated_version,
80
+ previous_version: resolve_latest_previous_version(
81
+ dep, updated_version
82
+ )
83
+ }
84
+ end
85
+ updates += updated_types_dependencies if types_update_available?
86
+ updates.uniq
87
+ end
88
+ # rubocop:enable Metrics/PerceivedComplexity
89
+
90
+ private
91
+
92
+ sig { returns(Dependabot::Dependency) }
93
+ attr_reader :dependency
94
+ attr_reader :credentials
95
+ attr_reader :dependency_files
96
+ attr_reader :latest_allowable_version
97
+ attr_reader :repo_contents_path
98
+ attr_reader :dependency_group
99
+
100
+ def latest_version_finder(dep)
101
+ @latest_version_finder[dep] ||=
102
+ LatestVersionFinder.new(
103
+ dependency: dep,
104
+ credentials: credentials,
105
+ dependency_files: dependency_files,
106
+ ignored_versions: [],
107
+ security_advisories: []
108
+ )
109
+ end
110
+
111
+ # rubocop:disable Metrics/PerceivedComplexity
112
+ def resolve_latest_previous_version(dep, updated_version)
113
+ return dep.version if dep.version
114
+
115
+ @resolve_latest_previous_version ||= {}
116
+ @resolve_latest_previous_version[dep] ||= begin
117
+ relevant_versions = latest_version_finder(dependency)
118
+ .possible_previous_versions_with_details
119
+ .map(&:first)
120
+ reqs = dep.requirements.filter_map { |r| r[:requirement] }
121
+ .map { |r| requirement_class.requirements_array(r) }
122
+
123
+ # Pick the lowest version from the max possible version from all
124
+ # requirements. This matches the logic when combining the same
125
+ # dependency in DependencySet from multiple manifest files where we
126
+ # pick the lowest version from the duplicates.
127
+ latest_previous_version = reqs.flat_map do |req|
128
+ relevant_versions.select do |version|
129
+ req.any? { |r| r.satisfied_by?(version) }
130
+ end.max
131
+ end.min&.to_s
132
+
133
+ # Handle cases where the latest resolvable previous version is the
134
+ # latest version. This often happens if you don't have lockfiles and
135
+ # have requirements update strategy set to bump_versions, where an
136
+ # update might go from ^1.1.1 to ^1.1.2 (both resolve to 1.1.2).
137
+ if updated_version.to_s == latest_previous_version
138
+ nil
139
+ else
140
+ latest_previous_version
141
+ end
142
+ end
143
+ end
144
+ # rubocop:enable Metrics/PerceivedComplexity
145
+
146
+ def part_of_tightly_locked_monorepo?
147
+ monorepo_dep_names =
148
+ TIGHTLY_COUPLED_MONOREPOS.values
149
+ .find { |deps| deps.include?(dependency.name) }
150
+ return false unless monorepo_dep_names
151
+
152
+ deps_to_update =
153
+ top_level_dependencies
154
+ .select { |d| monorepo_dep_names.include?(d.name) }
155
+
156
+ deps_to_update.count > 1
157
+ end
158
+
159
+ def updated_monorepo_dependencies
160
+ monorepo_dep_names =
161
+ TIGHTLY_COUPLED_MONOREPOS.values
162
+ .find { |deps| deps.include?(dependency.name) }
163
+
164
+ deps_to_update =
165
+ top_level_dependencies
166
+ .select { |d| monorepo_dep_names.include?(d.name) }
167
+
168
+ updates = []
169
+ deps_to_update.each do |dep|
170
+ next if git_dependency?(dep)
171
+ next if dep.version &&
172
+ version_class.new(dep.version) >= latest_allowable_version
173
+
174
+ updated_version =
175
+ latest_version_finder(dep)
176
+ .possible_versions
177
+ .find { |v| v == latest_allowable_version }
178
+ next unless updated_version
179
+
180
+ updates << {
181
+ dependency: dep,
182
+ version: updated_version,
183
+ previous_version: resolve_latest_previous_version(
184
+ dep, updated_version
185
+ )
186
+ }
187
+ end
188
+
189
+ updates
190
+ end
191
+
192
+ def types_package
193
+ @types_package ||= begin
194
+ types_package_name = Dependabot::Javascript::Shared::PackageName.new(dependency.name).types_package_name
195
+ top_level_dependencies.find { |d| types_package_name.to_s == d.name } if types_package_name
196
+ end
197
+ end
198
+
199
+ def original_package
200
+ @original_package ||= begin
201
+ original_package_name = Dependabot::Javascript::Shared::PackageName.new(dependency.name).library_name
202
+ top_level_dependencies.find { |d| original_package_name.to_s == d.name } if original_package_name
203
+ end
204
+ end
205
+
206
+ def latest_types_package_version
207
+ @latest_types_package_version ||= latest_version_finder(types_package).latest_version_from_registry
208
+ end
209
+
210
+ def types_update_available?
211
+ return false if types_package.nil?
212
+
213
+ return false if latest_types_package_version.nil?
214
+
215
+ return false unless latest_allowable_version.backwards_compatible_with?(latest_types_package_version)
216
+
217
+ return false unless version_class.correct?(types_package.version)
218
+
219
+ current_types_package_version = version_class.new(types_package.version)
220
+
221
+ return false unless current_types_package_version < latest_types_package_version
222
+
223
+ true
224
+ end
225
+
226
+ def original_package_update_available?
227
+ return false if original_package.nil?
228
+
229
+ return false unless version_class.correct?(original_package.version)
230
+
231
+ original_package_version = version_class.new(original_package.version)
232
+
233
+ latest_version = latest_version_finder(original_package).latest_version_from_registry
234
+
235
+ # If the latest version is within the scope of the current requirements,
236
+ # latest_version will be nil. In such cases, there is no update available.
237
+ return false if latest_version.nil?
238
+
239
+ original_package_version < latest_version
240
+ end
241
+
242
+ def updated_types_dependencies
243
+ [{
244
+ dependency: types_package,
245
+ version: latest_types_package_version,
246
+ previous_version: resolve_latest_previous_version(
247
+ types_package, latest_types_package_version
248
+ )
249
+ }]
250
+ end
251
+
252
+ def peer_dependency_errors
253
+ return @peer_dependency_errors if @peer_dependency_errors_checked
254
+
255
+ @peer_dependency_errors_checked = true
256
+
257
+ @peer_dependency_errors =
258
+ fetch_peer_dependency_errors(version: latest_allowable_version)
259
+ end
260
+
261
+ def old_peer_dependency_errors
262
+ return @old_peer_dependency_errors if @old_peer_dependency_errors_checked
263
+
264
+ @old_peer_dependency_errors_checked = true
265
+
266
+ version = version_for_dependency(dependency)
267
+
268
+ @old_peer_dependency_errors =
269
+ fetch_peer_dependency_errors(version: version)
270
+ end
271
+
272
+ def fetch_peer_dependency_errors(version:)
273
+ # TODO: Add all of the error handling that the FileUpdater does
274
+ # here (since problematic repos will be resolved here before they're
275
+ # seen by the FileUpdater)
276
+ base_dir = dependency_files.first.directory
277
+ SharedHelpers.in_a_temporary_repo_directory(base_dir, repo_contents_path) do
278
+ dependency_files_builder.write_temporary_dependency_files
279
+
280
+ paths_requiring_update_check.flat_map do |path|
281
+ run_checker(path: path, version: version)
282
+ end.compact
283
+ end
284
+ rescue SharedHelpers::HelperSubprocessFailed
285
+ # Fall back to allowing the version through. Whatever error
286
+ # occurred should be properly handled by the FileUpdater. We
287
+ # can slowly migrate error handling to this class over time.
288
+ []
289
+ end
290
+
291
+ def unmet_peer_dependencies
292
+ peer_dependency_errors
293
+ .map { |captures| error_details_from_captures(captures) }
294
+ end
295
+
296
+ def old_unmet_peer_dependencies
297
+ old_peer_dependency_errors
298
+ .map { |captures| error_details_from_captures(captures) }
299
+ end
300
+
301
+ def error_details_from_captures(captures)
302
+ return {} unless captures.is_a?(Hash)
303
+
304
+ required_dep_captures = captures.fetch("required_dep")
305
+ requiring_dep_captures = captures.fetch("requiring_dep")
306
+ return {} unless required_dep_captures && requiring_dep_captures
307
+
308
+ {
309
+ requirement_name: required_dep_captures.sub(/@[^@]+$/, ""),
310
+ requirement_version: required_dep_captures.split("@").last.delete('"'),
311
+ requiring_dep_name: requiring_dep_captures.sub(/@[^@]+$/, "")
312
+ }
313
+ end
314
+
315
+ def relevant_unmet_peer_dependencies
316
+ relevant_unmet_peer_dependencies =
317
+ unmet_peer_dependencies.select do |dep|
318
+ dep[:requirement_name] == dependency.name ||
319
+ dep[:requiring_dep_name] == dependency.name
320
+ end
321
+
322
+ unless dependency_group.nil?
323
+ # Ignore unmet peer dependencies that are in the dependency group because
324
+ # the update is also updating those dependencies.
325
+ relevant_unmet_peer_dependencies.reject! do |dep|
326
+ dependency_group.dependencies.any? do |group_dep|
327
+ dep[:requirement_name] == group_dep.name ||
328
+ dep[:requiring_dep_name] == group_dep.name
329
+ end
330
+ end
331
+ end
332
+
333
+ return [] if relevant_unmet_peer_dependencies.empty?
334
+
335
+ # Prune out any pre-existing warnings
336
+ relevant_unmet_peer_dependencies.reject do |issue|
337
+ old_unmet_peer_dependencies.any? do |old_issue|
338
+ old_issue.slice(:requirement_name, :requiring_dep_name) ==
339
+ issue.slice(:requirement_name, :requiring_dep_name)
340
+ end
341
+ end
342
+ end
343
+
344
+ # rubocop:disable Metrics/PerceivedComplexity
345
+ def satisfying_versions
346
+ latest_version_finder(dependency)
347
+ .possible_versions_with_details
348
+ .select do |version, details|
349
+ next false unless satisfies_peer_reqs_on_dep?(version)
350
+ next true unless details["peerDependencies"]
351
+ next true if version == version_for_dependency(dependency)
352
+
353
+ details["peerDependencies"].all? do |dep, req|
354
+ dep = top_level_dependencies.find { |d| d.name == dep }
355
+ next false unless dep
356
+ next git_dependency?(dep) if req.include?("/")
357
+
358
+ reqs = requirement_class.requirements_array(req)
359
+ next false unless version_for_dependency(dep)
360
+
361
+ reqs.any? { |r| r.satisfied_by?(version_for_dependency(dep)) }
362
+ rescue Gem::Requirement::BadRequirementError
363
+ false
364
+ end
365
+ end
366
+ .map(&:first)
367
+ end
368
+
369
+ # rubocop:enable Metrics/PerceivedComplexity
370
+
371
+ def satisfies_peer_reqs_on_dep?(version)
372
+ newly_broken_peer_reqs_on_dep.all? do |peer_req|
373
+ req = peer_req.fetch(:requirement_version)
374
+
375
+ # Git requirements can't be satisfied by a version
376
+ next false if req.include?("/")
377
+
378
+ reqs = requirement_class.requirements_array(req)
379
+ reqs.any? { |r| r.satisfied_by?(version) }
380
+ rescue Gem::Requirement::BadRequirementError
381
+ false
382
+ end
383
+ end
384
+
385
+ def latest_version_of_dep_with_satisfied_peer_reqs(dep)
386
+ latest_version_finder(dep)
387
+ .possible_versions_with_details
388
+ .find do |version, details|
389
+ next false unless version > version_for_dependency(dep)
390
+ next true unless details["peerDependencies"]
391
+
392
+ details["peerDependencies"].all? do |peer_dep_name, req|
393
+ # Can't handle multiple peer dependencies
394
+ next false unless peer_dep_name == dependency.name
395
+ next git_dependency?(dependency) if req.include?("/")
396
+
397
+ reqs = requirement_class.requirements_array(req)
398
+
399
+ reqs.any? { |r| r.satisfied_by?(latest_allowable_version) }
400
+ rescue Gem::Requirement::BadRequirementError
401
+ false
402
+ end
403
+ end
404
+ &.first
405
+ end
406
+
407
+ def git_dependency?(dep)
408
+ # ignored_version/raise_on_ignored are irrelevant.
409
+ GitCommitChecker
410
+ .new(dependency: dep, credentials: credentials)
411
+ .git_dependency?
412
+ end
413
+
414
+ def newly_broken_peer_reqs_on_dep
415
+ relevant_unmet_peer_dependencies
416
+ .select { |dep| dep[:requirement_name] == dependency.name }
417
+ end
418
+
419
+ def newly_broken_peer_reqs_from_dep
420
+ relevant_unmet_peer_dependencies
421
+ .select { |dep| dep[:requiring_dep_name] == dependency.name }
422
+ end
423
+
424
+ def lockfiles_for_path(lockfiles:, path:)
425
+ lockfiles.select do |lockfile|
426
+ File.dirname(lockfile.name) == File.dirname(path)
427
+ end
428
+ end
429
+
430
+ def run_checker(path:, version:)
431
+ bun_lockfiles = lockfiles_for_path(lockfiles: dependency_files_builder.bun_locks, path: path)
432
+ return run_bun_checker(path: path, version: version) if bun_lockfiles.any?
433
+
434
+ root_bun_lock = dependency_files_builder.root_bun_lock
435
+ run_bun_checker(path: path, version: version) if root_bun_lock
436
+ end
437
+
438
+ def run_bun_checker(path:, version:)
439
+ SharedHelpers.with_git_configured(credentials: credentials) do
440
+ Dir.chdir(path) do
441
+ Helpers.run_bun_command(
442
+ "update #{dependency.name}@#{version} --save-text-lockfile",
443
+ fingerprint: "update <dependency_name>@<version> --save-text-lockfile"
444
+ )
445
+ end
446
+ end
447
+ end
448
+
449
+ def version_install_arg(version:)
450
+ git_source = dependency.requirements.find { |req| req[:source] && req[:source][:type] == "git" }
451
+
452
+ if git_source
453
+ "#{dependency.name}@#{git_source[:source][:url]}##{version}"
454
+ else
455
+ "#{dependency.name}@#{version}"
456
+ end
457
+ end
458
+
459
+ def requirements_for_path(requirements, path)
460
+ return requirements if path.to_s == "."
461
+
462
+ requirements.filter_map do |r|
463
+ next unless r[:file].start_with?("#{path}/")
464
+
465
+ r.merge(file: r[:file].gsub(/^#{Regexp.quote("#{path}/")}/, ""))
466
+ end
467
+ end
468
+
469
+ # Top level dependencies are required in the peer dep checker
470
+ # to fetch the manifests for all top level deps which may contain
471
+ # "peerDependency" requirements
472
+ def top_level_dependencies
473
+ @top_level_dependencies ||= Bun::FileParser.new(
474
+ dependency_files: dependency_files,
475
+ source: nil,
476
+ credentials: credentials
477
+ ).parse.select(&:top_level?)
478
+ end
479
+
480
+ def paths_requiring_update_check
481
+ @paths_requiring_update_check ||=
482
+ Dependabot::Javascript::Shared::DependencyFilesFilterer.new(
483
+ dependency_files: dependency_files,
484
+ updated_dependencies: [dependency],
485
+ lockfile_parser_class: FileParser::LockfileParser
486
+ ).paths_requiring_update_check
487
+ end
488
+
489
+ def dependency_files_builder
490
+ @dependency_files_builder ||=
491
+ DependencyFilesBuilder.new(
492
+ dependency: dependency,
493
+ dependency_files: dependency_files,
494
+ credentials: credentials
495
+ )
496
+ end
497
+
498
+ def version_for_dependency(dep)
499
+ return version_class.new(dep.version) if dep.version && version_class.correct?(dep.version)
500
+
501
+ dep.requirements.filter_map { |r| r[:requirement] }
502
+ .reject { |req_string| req_string.start_with?("<") }
503
+ .select { |req_string| req_string.match?(version_regex) }
504
+ .map { |req_string| req_string.match(version_regex) }
505
+ .select { |version| version_class.correct?(version.to_s) }
506
+ .map { |version| version_class.new(version.to_s) }
507
+ .max
508
+ end
509
+
510
+ def version_class
511
+ dependency.version_class
512
+ end
513
+
514
+ def requirement_class
515
+ dependency.requirement_class
516
+ end
517
+
518
+ def version_regex
519
+ Dependabot::Javascript::Shared::Version::VERSION_PATTERN
520
+ end
521
+ end
522
+ end
523
+ end
524
+ end
525
+ end
@@ -0,0 +1,165 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "stringio"
5
+
6
+ module Dependabot
7
+ module Javascript
8
+ module Bun
9
+ class UpdateChecker
10
+ class VulnerabilityAuditor
11
+ def initialize(dependency_files:, credentials:)
12
+ @dependency_files = dependency_files
13
+ @credentials = credentials
14
+ end
15
+
16
+ # rubocop:disable Metrics/MethodLength
17
+ # Finds any dependencies in the `package-lock.json` or `npm-shrinkwrap.json` that have
18
+ # a subdependency on the given dependency that is locked to a vuln version range.
19
+ #
20
+ # NOTE: yarn is currently not supported.
21
+ #
22
+ # @param dependency [Dependabot::Dependency] the dependency to check
23
+ # @param security_advisories [Array<Dependabot::SecurityAdvisory>] advisories for the dependency
24
+ # @return [Hash<String, [String, Array<Hash<String, String>>]>] the audit results
25
+ # * :dependency_name [String] the name of the dependency
26
+ # * :fix_available [Boolean] whether a fix is available
27
+ # * :current_version [String] the version of the dependency
28
+ # * :target_version [String] the version of the dependency after the fix
29
+ # * :fix_updates [Array<Hash<String, String>>] a list of dependencies to update in order to fix
30
+ # * :dependency_name [String] the name of the blocking dependency
31
+ # * :current_version [String] the current version of the blocking dependency
32
+ # * :target_version [String] the target version of the blocking dependency
33
+ # * :top_level_ancestors [Array<String>] the names of top-level dependencies with a transitive
34
+ # dependency on the blocking dependency
35
+ # * :top_level_ancestors [Array<String>] the names of all top-level dependencies with a transitive
36
+ # dependency on the dependency
37
+ # * :explanation [String] an explanation for why the project failed the vulnerability auditor run
38
+ def audit(dependency:, security_advisories:)
39
+ Dependabot.logger.info("VulnerabilityAuditor: starting audit")
40
+
41
+ fix_unavailable = {
42
+ "dependency_name" => dependency.name,
43
+ "fix_available" => false,
44
+ "fix_updates" => [],
45
+ "top_level_ancestors" => []
46
+ }
47
+
48
+ SharedHelpers.in_a_temporary_directory do
49
+ dependency_files_builder = DependencyFilesBuilder.new(
50
+ dependency: dependency,
51
+ dependency_files: dependency_files,
52
+ credentials: credentials
53
+ )
54
+ dependency_files_builder.write_temporary_dependency_files
55
+
56
+ # `npm-shrinkwrap.js`, if present, takes precedence over `package-lock.js`.
57
+ # Both files use the same format. See https://bit.ly/3lDIAJV for more.
58
+ lockfile = dependency_files_builder.lockfiles.first
59
+ unless lockfile
60
+ Dependabot.logger.info("VulnerabilityAuditor: missing lockfile")
61
+ return fix_unavailable
62
+ end
63
+
64
+ vuln_versions = security_advisories.map do |a|
65
+ {
66
+ dependency_name: a.dependency_name,
67
+ affected_versions: a.vulnerable_version_strings
68
+ }
69
+ end
70
+
71
+ audit_result = SharedHelpers.run_helper_subprocess(
72
+ command: Dependabot::Javascript::Shared::NativeHelpers.helper_path,
73
+ function: "npm:vulnerabilityAuditor",
74
+ args: [Dir.pwd, vuln_versions]
75
+ )
76
+
77
+ validation_result = validate_audit_result(audit_result, security_advisories)
78
+ if validation_result != :viable
79
+ Dependabot.logger.info("VulnerabilityAuditor: audit result not viable: #{validation_result}")
80
+ fix_unavailable["explanation"] = explain_fix_unavailable(validation_result, dependency)
81
+ return fix_unavailable
82
+ end
83
+
84
+ Dependabot.logger.info("VulnerabilityAuditor: audit result viable")
85
+ audit_result
86
+ end
87
+ rescue SharedHelpers::HelperSubprocessFailed => e
88
+ log_helper_subprocess_failure(dependency, e)
89
+ fix_unavailable
90
+ end
91
+ # rubocop:enable Metrics/MethodLength
92
+
93
+ private
94
+
95
+ attr_reader :dependency_files
96
+ attr_reader :credentials
97
+
98
+ def explain_fix_unavailable(validation_result, dependency)
99
+ case validation_result
100
+ when :fix_unavailable, :dependency_still_vulnerable, :downgrades_dependencies
101
+ "No patched version available for #{dependency.name}"
102
+ when :fix_incomplete
103
+ "The lockfile might be out of sync?"
104
+ end
105
+ end
106
+
107
+ def validate_audit_result(audit_result, security_advisories)
108
+ return :fix_unavailable unless audit_result["fix_available"]
109
+ return :dependency_still_vulnerable if dependency_still_vulnerable?(audit_result, security_advisories)
110
+ return :downgrades_dependencies if downgrades_dependencies?(audit_result)
111
+ return :fix_incomplete if fix_incomplete?(audit_result)
112
+
113
+ :viable
114
+ end
115
+
116
+ def dependency_still_vulnerable?(audit_result, security_advisories)
117
+ # vulnerable dependency is removed if the target version is nil
118
+ return false unless audit_result["target_version"]
119
+
120
+ version = Version.new(audit_result["target_version"])
121
+ security_advisories.any? { |a| a.vulnerable?(version) }
122
+ end
123
+
124
+ def downgrades_dependencies?(audit_result)
125
+ return true if downgrades_version?(audit_result["current_version"], audit_result["target_version"])
126
+
127
+ audit_result["fix_updates"].any? do |update|
128
+ downgrades_version?(update["current_version"], update["target_version"])
129
+ end
130
+ end
131
+
132
+ def downgrades_version?(current_version, target_version)
133
+ return false unless target_version
134
+
135
+ current = Version.new(current_version)
136
+ target = Version.new(target_version)
137
+ current > target
138
+ end
139
+
140
+ def fix_incomplete?(audit_result)
141
+ audit_result["fix_updates"].any? { |update| !update.key?("target_version") } ||
142
+ audit_result["fix_updates"].empty?
143
+ end
144
+
145
+ def log_helper_subprocess_failure(dependency, error)
146
+ # See `Dependabot::SharedHelpers.run_helper_subprocess` for details on error context
147
+ context = error.error_context || {}
148
+
149
+ builder = ::StringIO.new
150
+ builder << "VulnerabilityAuditor: "
151
+ builder << "#{context[:function]} " if context[:function]
152
+ builder << "failed"
153
+ builder << " after #{context[:time_taken].truncate(2)}s" if context[:time_taken]
154
+ builder << " while auditing #{dependency.name}: "
155
+ builder << error.message
156
+ builder << "\n" << context[:trace]
157
+
158
+ msg = builder.string
159
+ Dependabot.logger.info(msg) # TODO: is this the right log level?
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end