dependabot-go_modules 0.87.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "dependabot/dependency"
5
+ require "dependabot/file_parsers/base/dependency_set"
6
+ require "dependabot/go_modules/path_converter"
7
+ require "dependabot/errors"
8
+ require "dependabot/file_parsers"
9
+ require "dependabot/file_parsers/base"
10
+
11
+ module Dependabot
12
+ module GoModules
13
+ class FileParser < Dependabot::FileParsers::Base
14
+ GIT_VERSION_REGEX = /^v\d+\.\d+\.\d+-.*-(?<sha>[0-9a-f]{12})$/.freeze
15
+
16
+ def parse
17
+ dependency_set = Dependabot::FileParsers::Base::DependencySet.new
18
+
19
+ i = 0
20
+ chunks = module_info(go_mod).lines.
21
+ group_by { |line| line == "{\n" ? i += 1 : i }
22
+ deps = chunks.values.map { |chunk| JSON.parse(chunk.join) }
23
+
24
+ deps.each do |dep|
25
+ # The project itself appears in this list as "Main"
26
+ next if dep["Main"]
27
+
28
+ dependency = dependency_from_details(dep)
29
+ dependency_set << dependency if dependency
30
+ end
31
+
32
+ dependency_set.dependencies
33
+ end
34
+
35
+ private
36
+
37
+ def go_mod
38
+ @go_mod ||= get_original_file("go.mod")
39
+ end
40
+
41
+ def check_required_files
42
+ raise "No go.mod!" unless go_mod
43
+ end
44
+
45
+ def dependency_from_details(details)
46
+ source =
47
+ if rev_identifier?(details) then git_source(details)
48
+ else { type: "default", source: details["Path"] }
49
+ end
50
+
51
+ version = details["Version"]&.sub(/^v?/, "")
52
+
53
+ reqs = [{
54
+ requirement: rev_identifier?(details) ? nil : details["Version"],
55
+ file: go_mod.name,
56
+ source: source,
57
+ groups: []
58
+ }]
59
+
60
+ Dependency.new(
61
+ name: details["Path"],
62
+ version: version,
63
+ requirements: details["Indirect"] ? [] : reqs,
64
+ package_manager: "dep"
65
+ )
66
+ end
67
+
68
+ def module_info(go_mod)
69
+ @module_info ||=
70
+ SharedHelpers.in_a_temporary_directory do |path|
71
+ SharedHelpers.with_git_configured(credentials: credentials) do
72
+ File.write("go.mod", go_mod.content)
73
+
74
+ command = "GO111MODULE=on go mod edit -print > /dev/null"
75
+ command += " && GO111MODULE=on go list -m -json all"
76
+ stdout, stderr, status = Open3.capture3(command)
77
+ handle_parser_error(path, stderr) unless status.success?
78
+ stdout
79
+ end
80
+ end
81
+ end
82
+
83
+ def handle_parser_error(path, stderr)
84
+ case stderr
85
+ when /go: .*: unknown revision/
86
+ line = stderr.lines.grep(/unknown revision/).first
87
+ raise Dependabot::DependencyFileNotResolvable, line.strip
88
+ when /go: .*: unrecognized import path/
89
+ line = stderr.lines.grep(/unrecognized import/).first
90
+ raise Dependabot::DependencyFileNotResolvable, line.strip
91
+ when /go: errors parsing go.mod/
92
+ msg = stderr.gsub(path.to_s, "").strip
93
+ raise Dependabot::DependencyFileNotParseable.new(go_mod.path, msg)
94
+ else
95
+ msg = stderr.gsub(path.to_s, "").strip
96
+ raise Dependabot::DependencyFileNotParseable.new(go_mod.path, msg)
97
+ end
98
+ end
99
+
100
+ def rev_identifier?(dep)
101
+ dep["Version"]&.match?(GIT_VERSION_REGEX)
102
+ end
103
+
104
+ def git_source(dep)
105
+ url = PathConverter.git_url_for_path(dep["Path"])
106
+
107
+ # Currently, we have no way of knowing whether the commit tagged
108
+ # is being used because a branch is being followed or because a
109
+ # particular ref is in use. We *assume* that a particular ref is in
110
+ # use (which means we'll only propose updates when its included in
111
+ # a release)
112
+ {
113
+ type: "git",
114
+ url: url || dep["Path"],
115
+ ref: git_revision(dep),
116
+ branch: nil
117
+ }
118
+ end
119
+
120
+ def git_revision(dep)
121
+ raw_version = dep.fetch("Version")
122
+ return raw_version unless raw_version.match?(GIT_VERSION_REGEX)
123
+
124
+ raw_version.match(GIT_VERSION_REGEX).named_captures.fetch("sha")
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ Dependabot::FileParsers.
131
+ register("go_modules", Dependabot::GoModules::FileParser)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/shared_helpers"
4
+ require "dependabot/file_updaters"
5
+ require "dependabot/file_updaters/base"
6
+
7
+ module Dependabot
8
+ module GoModules
9
+ class FileUpdater < Dependabot::FileUpdaters::Base
10
+ require_relative "file_updater/go_mod_updater"
11
+
12
+ def self.updated_files_regex
13
+ [
14
+ /^go\.mod$/,
15
+ /^go\.sum$/
16
+ ]
17
+ end
18
+
19
+ def updated_dependency_files
20
+ updated_files = []
21
+
22
+ if go_mod && file_changed?(go_mod)
23
+ updated_files <<
24
+ updated_file(
25
+ file: go_mod,
26
+ content: file_updater.updated_go_mod_content
27
+ )
28
+
29
+ if go_sum && go_sum.content != file_updater.updated_go_sum_content
30
+ updated_files <<
31
+ updated_file(
32
+ file: go_sum,
33
+ content: file_updater.updated_go_sum_content
34
+ )
35
+ end
36
+ end
37
+
38
+ raise "No files changed!" if updated_files.none?
39
+
40
+ updated_files
41
+ end
42
+
43
+ private
44
+
45
+ def check_required_files
46
+ return if go_mod
47
+
48
+ raise "No go.mod!"
49
+ end
50
+
51
+ def go_mod
52
+ @go_mod ||= get_original_file("go.mod")
53
+ end
54
+
55
+ def go_sum
56
+ @go_sum ||= get_original_file("go.sum")
57
+ end
58
+
59
+ def file_updater
60
+ @file_updater ||=
61
+ GoModUpdater.new(
62
+ dependencies: dependencies,
63
+ go_mod: go_mod,
64
+ go_sum: go_sum,
65
+ credentials: credentials
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ Dependabot::FileUpdaters.
73
+ register("go_modules", Dependabot::GoModules::FileUpdater)
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/utils/go/shared_helper"
4
+ require "dependabot/go_modules/file_updater"
5
+ require "dependabot/go_modules/native_helpers"
6
+
7
+ module Dependabot
8
+ module GoModules
9
+ class FileUpdater
10
+ class GoModUpdater
11
+ def initialize(dependencies:, go_mod:, go_sum:, credentials:)
12
+ @dependencies = dependencies
13
+ @go_mod = go_mod
14
+ @go_sum = go_sum
15
+ @credentials = credentials
16
+ end
17
+
18
+ def updated_go_mod_content
19
+ @updated_go_mod_content ||=
20
+ SharedHelpers.in_a_temporary_directory do
21
+ SharedHelpers.with_git_configured(credentials: credentials) do
22
+ File.write("go.mod", go_mod.content)
23
+
24
+ deps = dependencies.map do |dep|
25
+ {
26
+ name: dep.name,
27
+ version: "v" + dep.version.sub(/^v/i, ""),
28
+ indirect: dep.requirements.empty?
29
+ }
30
+ end
31
+
32
+ SharedHelpers.run_helper_subprocess(
33
+ command: "GO111MODULE=on #{NativeHelpers.helper_path}",
34
+ function: "updateDependencyFile",
35
+ args: { dependencies: deps }
36
+ )
37
+ end
38
+ end
39
+ end
40
+
41
+ def updated_go_sum_content
42
+ return nil unless go_sum
43
+
44
+ # This needs to be run separately so we don't nest subprocess calls
45
+ updated_go_mod_content
46
+
47
+ @updated_go_sum_content ||=
48
+ SharedHelpers.in_a_temporary_directory do
49
+ SharedHelpers.with_git_configured(credentials: credentials) do
50
+ File.write("go.mod", updated_go_mod_content)
51
+ File.write("go.sum", go_sum.content)
52
+ File.write("main.go", dummy_main_go)
53
+
54
+ `GO111MODULE=on go get -d`
55
+ unless $CHILD_STATUS.success?
56
+ raise Dependabot::DependencyFileNotParseable, go_sum.path
57
+ end
58
+
59
+ File.read("go.sum")
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def dummy_main_go
67
+ lines = ["package main", "import ("]
68
+ dependencies.each do |dep|
69
+ lines << "_ \"#{dep.name}\""
70
+ end
71
+ lines << ")"
72
+ lines << "func main() {}"
73
+ lines.join("\n")
74
+ end
75
+
76
+ attr_reader :dependencies, :go_mod, :go_sum, :credentials
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/metadata_finders"
4
+ require "dependabot/metadata_finders/base"
5
+ require "dependabot/go_modules/path_converter"
6
+
7
+ module Dependabot
8
+ module GoModules
9
+ class MetadataFinder < Dependabot::MetadataFinders::Base
10
+ private
11
+
12
+ def look_up_source
13
+ return look_up_git_dependency_source if git_dependency?
14
+
15
+ path_str = (specified_source_string || dependency.name)
16
+ url = Dependabot::GoModules::PathConverter.
17
+ git_url_for_path_without_go_helper(path_str)
18
+ Source.from_url(url) if url
19
+ end
20
+
21
+ def git_dependency?
22
+ return false unless declared_source_details
23
+
24
+ dependency_type =
25
+ declared_source_details.fetch(:type, nil) ||
26
+ declared_source_details.fetch("type")
27
+
28
+ dependency_type == "git"
29
+ end
30
+
31
+ def look_up_git_dependency_source
32
+ specified_url =
33
+ declared_source_details.fetch(:url, nil) ||
34
+ declared_source_details.fetch("url")
35
+
36
+ Source.from_url(specified_url)
37
+ end
38
+
39
+ def specified_source_string
40
+ declared_source_details&.fetch(:source, nil) ||
41
+ declared_source_details&.fetch("source", nil)
42
+ end
43
+
44
+ def declared_source_details
45
+ sources = dependency.requirements.
46
+ map { |r| r.fetch(:source) }.
47
+ uniq.compact
48
+
49
+ raise "Multiple sources! #{sources.join(', ')}" if sources.count > 1
50
+
51
+ sources.first
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ Dependabot::MetadataFinders.
58
+ register("go_modules", Dependabot::GoModules::MetadataFinder)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dependabot
4
+ module GoModules
5
+ module NativeHelpers
6
+ def self.helper_path
7
+ clean_path(File.join(native_helpers_root, "go_modules/bin/helper"))
8
+ end
9
+
10
+ def self.native_helpers_root
11
+ default_path = File.join(__dir__, "../../../helpers/install-dir")
12
+ ENV.fetch("DEPENDABOT_NATIVE_HELPERS_PATH", default_path)
13
+ end
14
+
15
+ def self.clean_path(path)
16
+ Pathname.new(path).cleanpath.to_path
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "excon"
4
+ require "nokogiri"
5
+
6
+ require "dependabot/shared_helpers"
7
+ require "dependabot/source"
8
+ require "dependabot/go_modules/native_helpers"
9
+
10
+ module Dependabot
11
+ module GoModules
12
+ module PathConverter
13
+ def self.git_url_for_path(path)
14
+ # Save a query by manually converting golang.org/x names
15
+ import_path = path.gsub(%r{^golang\.org/x}, "github.com/golang")
16
+
17
+ SharedHelpers.run_helper_subprocess(
18
+ command: NativeHelpers.helper_path,
19
+ function: "getVcsRemoteForImport",
20
+ args: { import: import_path }
21
+ )
22
+ end
23
+
24
+ # Used in dependabot-backend, which doesn't have access to any Go
25
+ # helpers.
26
+ # TODO: remove the need for this.
27
+ def self.git_url_for_path_without_go_helper(path)
28
+ # Save a query by manually converting golang.org/x names
29
+ tmp_path = path.gsub(%r{^golang\.org/x}, "github.com/golang")
30
+
31
+ # Currently, Dependabot::Source.new will return `nil` if it can't
32
+ # find a git SCH associated with a path. If it is ever extended to
33
+ # handle non-git sources we'll need to add an additional check here.
34
+ return Source.from_url(tmp_path).url if Source.from_url(tmp_path)
35
+ return "https://#{tmp_path}" if tmp_path.end_with?(".git")
36
+ return unless (metadata_response = fetch_path_metadata(path))
37
+
38
+ # Look for a GitHub, Bitbucket or GitLab URL in the response
39
+ metadata_response.scan(Dependabot::Source::SOURCE_REGEX) do
40
+ source_url = Regexp.last_match.to_s
41
+ return Source.from_url(source_url).url
42
+ end
43
+
44
+ # If none are found, parse the response and return the go-import path
45
+ doc = Nokogiri::XML(metadata_response)
46
+ doc.remove_namespaces!
47
+ import_details =
48
+ doc.xpath("//meta").
49
+ find { |n| n.attributes["name"]&.value == "go-import" }&.
50
+ attributes&.fetch("content")&.value&.split(/\s+/)
51
+ return unless import_details && import_details[1] == "git"
52
+
53
+ import_details[2]
54
+ end
55
+
56
+ def self.fetch_path_metadata(path)
57
+ # TODO: This is not robust! Instead, we should shell out to Go and
58
+ # use https://github.com/Masterminds/vcs.
59
+ response = Excon.get(
60
+ "https://#{path}?go-get=1",
61
+ idempotent: true,
62
+ **SharedHelpers.excon_defaults
63
+ )
64
+
65
+ return unless response.status == 200
66
+
67
+ response.body
68
+ end
69
+ private_class_method :fetch_path_metadata
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ ################################################################################
4
+ # For more details on Go version constraints, see: #
5
+ # - https://github.com/Masterminds/semver #
6
+ # - https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md #
7
+ ################################################################################
8
+
9
+ require "dependabot/go_modules/version"
10
+
11
+ module Dependabot
12
+ module GoModules
13
+ class Requirement < Gem::Requirement
14
+ WILDCARD_REGEX = /(?:\.|^)[xX*]/.freeze
15
+ OR_SEPARATOR = /(?<=[a-zA-Z0-9*])\s*\|{2}/.freeze
16
+
17
+ # Override the version pattern to allow a 'v' prefix
18
+ quoted = OPS.keys.map { |k| Regexp.quote(k) }.join("|")
19
+ version_pattern = "v?#{Version::VERSION_PATTERN}"
20
+
21
+ PATTERN_RAW = "\\s*(#{quoted})?\\s*(#{version_pattern})\\s*"
22
+ PATTERN = /\A#{PATTERN_RAW}\z/.freeze
23
+
24
+ # Use GoModules::Version rather than Gem::Version to ensure that
25
+ # pre-release versions aren't transformed.
26
+ def self.parse(obj)
27
+ return ["=", Version.new(obj.to_s)] if obj.is_a?(Gem::Version)
28
+
29
+ unless (matches = PATTERN.match(obj.to_s))
30
+ msg = "Illformed requirement [#{obj.inspect}]"
31
+ raise BadRequirementError, msg
32
+ end
33
+
34
+ return DefaultRequirement if matches[1] == ">=" && matches[2] == "0"
35
+
36
+ [matches[1] || "=", Version.new(matches[2])]
37
+ end
38
+
39
+ # Returns an array of requirements. At least one requirement from the
40
+ # returned array must be satisfied for a version to be valid.
41
+ def self.requirements_array(requirement_string)
42
+ return [new(nil)] if requirement_string.nil?
43
+
44
+ requirement_string.strip.split(OR_SEPARATOR).map do |req_string|
45
+ new(req_string)
46
+ end
47
+ end
48
+
49
+ def initialize(*requirements)
50
+ requirements = requirements.flatten.flat_map do |req_string|
51
+ req_string.split(",").map do |r|
52
+ convert_go_constraint_to_ruby_constraint(r.strip)
53
+ end
54
+ end
55
+
56
+ super(requirements)
57
+ end
58
+
59
+ private
60
+
61
+ def convert_go_constraint_to_ruby_constraint(req_string)
62
+ req_string = req_string
63
+ req_string = convert_wildcard_characters(req_string)
64
+
65
+ if req_string.match?(WILDCARD_REGEX)
66
+ ruby_range(req_string.gsub(WILDCARD_REGEX, "").gsub(/^[^\d]/, ""))
67
+ elsif req_string.match?(/^~[^>]/) then convert_tilde_req(req_string)
68
+ elsif req_string.include?(" - ") then convert_hyphen_req(req_string)
69
+ elsif req_string.match?(/^[\dv^]/) then convert_caret_req(req_string)
70
+ elsif req_string.match?(/[<=>]/) then req_string
71
+ else ruby_range(req_string)
72
+ end
73
+ end
74
+
75
+ def convert_wildcard_characters(req_string)
76
+ if req_string.match?(/^[\dv^>~]/)
77
+ replace_wildcard_in_lower_bound(req_string)
78
+ elsif req_string.start_with?("<")
79
+ parts = req_string.split(".")
80
+ parts.map.with_index do |part, index|
81
+ next "0" if part.match?(WILDCARD_REGEX)
82
+ next part.to_i + 1 if parts[index + 1]&.match?(WILDCARD_REGEX)
83
+
84
+ part
85
+ end.join(".")
86
+ else
87
+ req_string
88
+ end
89
+ end
90
+
91
+ def replace_wildcard_in_lower_bound(req_string)
92
+ after_wildcard = false
93
+
94
+ if req_string.start_with?("~")
95
+ req_string = req_string.gsub(/(?:(?:\.|^)[xX*])(\.[xX*])+/, "")
96
+ end
97
+
98
+ req_string.split(".").
99
+ map do |part|
100
+ part.split("-").map.with_index do |p, i|
101
+ # Before we hit a wildcard we just return the existing part
102
+ next p unless p.match?(WILDCARD_REGEX) || after_wildcard
103
+
104
+ # On or after a wildcard we replace the version part with zero
105
+ after_wildcard = true
106
+ i.zero? ? "0" : "a"
107
+ end.join("-")
108
+ end.join(".")
109
+ end
110
+
111
+ def convert_tilde_req(req_string)
112
+ version = req_string.gsub(/^~/, "")
113
+ parts = version.split(".")
114
+ parts << "0" if parts.count < 3
115
+ "~> #{parts.join('.')}"
116
+ end
117
+
118
+ def convert_hyphen_req(req_string)
119
+ lower_bound, upper_bound = req_string.split(/\s+-\s+/)
120
+ [">= #{lower_bound}", "<= #{upper_bound}"]
121
+ end
122
+
123
+ def ruby_range(req_string)
124
+ parts = req_string.split(".")
125
+
126
+ # If we have three or more parts then this is an exact match
127
+ return req_string if parts.count >= 3
128
+
129
+ # If we have no parts then the version is completely unlocked
130
+ return ">= 0" if parts.count.zero?
131
+
132
+ # If we have fewer than three parts we do a partial match
133
+ parts << "0"
134
+ "~> #{parts.join('.')}"
135
+ end
136
+
137
+ # Note: Dep's caret notation implementation doesn't distinguish between
138
+ # pre and post-1.0.0 requirements (unlike in JS)
139
+ def convert_caret_req(req_string)
140
+ version = req_string.gsub(/^\^?v?/, "")
141
+ parts = version.split(".")
142
+ upper_bound = [parts.first.to_i + 1, 0, 0, "a"].map(&:to_s).join(".")
143
+
144
+ [">= #{version}", "< #{upper_bound}"]
145
+ end
146
+ end
147
+ end
148
+ end