dependabot-nuget 0.80.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/nuget/file_parser"
4
+ require "dependabot/nuget/update_checker"
5
+
6
+ module Dependabot
7
+ module Nuget
8
+ class UpdateChecker
9
+ class PropertyUpdater
10
+ require_relative "version_finder"
11
+ require_relative "requirements_updater"
12
+
13
+ def initialize(dependency:, dependency_files:, credentials:,
14
+ target_version_details:, ignored_versions:)
15
+ @dependency = dependency
16
+ @dependency_files = dependency_files
17
+ @credentials = credentials
18
+ @ignored_versions = ignored_versions
19
+ @target_version = target_version_details&.fetch(:version)
20
+ @source_details = target_version_details&.
21
+ slice(:nuspec_url, :repo_url, :source_url)
22
+ end
23
+
24
+ def update_possible?
25
+ return false unless target_version
26
+
27
+ @update_possible ||=
28
+ dependencies_using_property.all? do |dep|
29
+ versions = VersionFinder.new(
30
+ dependency: dep,
31
+ dependency_files: dependency_files,
32
+ credentials: credentials,
33
+ ignored_versions: ignored_versions
34
+ ).versions.map { |v| v.fetch(:version) }
35
+
36
+ versions.include?(target_version) || versions.none?
37
+ end
38
+ end
39
+
40
+ def updated_dependencies
41
+ raise "Update not possible!" unless update_possible?
42
+
43
+ @updated_dependencies ||=
44
+ dependencies_using_property.map do |dep|
45
+ Dependency.new(
46
+ name: dep.name,
47
+ version: target_version.to_s,
48
+ requirements: updated_requirements(dep),
49
+ previous_version: dep.version,
50
+ previous_requirements: dep.requirements,
51
+ package_manager: dep.package_manager
52
+ )
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :dependency, :dependency_files, :target_version,
59
+ :source_details, :credentials, :ignored_versions
60
+
61
+ def dependencies_using_property
62
+ @dependencies_using_property ||=
63
+ Nuget::FileParser.new(
64
+ dependency_files: dependency_files,
65
+ source: nil
66
+ ).parse.select do |dep|
67
+ dep.requirements.any? do |r|
68
+ r.dig(:metadata, :property_name) == property_name
69
+ end
70
+ end
71
+ end
72
+
73
+ def property_name
74
+ @property_name ||= dependency.requirements.
75
+ find { |r| r.dig(:metadata, :property_name) }&.
76
+ dig(:metadata, :property_name)
77
+
78
+ raise "No requirement with a property name!" unless @property_name
79
+
80
+ @property_name
81
+ end
82
+
83
+ def updated_requirements(dep)
84
+ @updated_requirements ||= {}
85
+ @updated_requirements[dep.name] ||=
86
+ RequirementsUpdater.new(
87
+ requirements: dep.requirements,
88
+ latest_version: target_version,
89
+ source_details: source_details
90
+ ).updated_requirements
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "excon"
4
+ require "nokogiri"
5
+ require "dependabot/errors"
6
+ require "dependabot/nuget/update_checker"
7
+ require "dependabot/shared_helpers"
8
+
9
+ module Dependabot
10
+ module Nuget
11
+ class UpdateChecker
12
+ class RepositoryFinder
13
+ DEFAULT_REPOSITORY_URL = "https://api.nuget.org/v3/index.json"
14
+
15
+ def initialize(dependency:, credentials:, config_file: nil)
16
+ @dependency = dependency
17
+ @credentials = credentials
18
+ @config_file = config_file
19
+ end
20
+
21
+ def dependency_urls
22
+ find_dependency_urls
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :dependency, :credentials, :config_file
28
+
29
+ def find_dependency_urls
30
+ @find_dependency_urls ||=
31
+ known_repositories.flat_map do |details|
32
+ if details.fetch(:url) == DEFAULT_REPOSITORY_URL
33
+ # Save a request for the default URL, since we already how
34
+ # it addresses packages
35
+ next default_repository_details
36
+ end
37
+
38
+ build_url_for_details(details)
39
+ end.compact.uniq
40
+ end
41
+
42
+ def build_url_for_details(repo_details)
43
+ response = get_repo_metadata(repo_details)
44
+ check_repo_reponse(response, repo_details)
45
+ return unless response.status == 200
46
+
47
+ base_url = base_url_from_v3_metadata(JSON.parse(response.body))
48
+ search_url = search_url_from_v3_metadata(JSON.parse(response.body))
49
+
50
+ details = {
51
+ repository_url: repo_details.fetch(:url),
52
+ auth_header: auth_header_for_token(repo_details.fetch(:token)),
53
+ repository_type: "v3"
54
+ }
55
+ if base_url
56
+ details[:versions_url] =
57
+ File.join(base_url, dependency.name.downcase, "index.json")
58
+ end
59
+ if search_url
60
+ details[:search_url] =
61
+ search_url + "?q=#{dependency.name.downcase}&prerelease=true"
62
+ end
63
+ details
64
+ rescue JSON::ParserError
65
+ build_v2_url(response, repo_details)
66
+ rescue Excon::Error::Timeout, Excon::Error::Socket
67
+ handle_timeout(repo_metadata_url: repo_details.fetch(:url))
68
+ end
69
+
70
+ def get_repo_metadata(repo_details)
71
+ Excon.get(
72
+ repo_details.fetch(:url),
73
+ headers: auth_header_for_token(repo_details.fetch(:token)),
74
+ idempotent: true,
75
+ **SharedHelpers.excon_defaults
76
+ )
77
+ end
78
+
79
+ def base_url_from_v3_metadata(metadata)
80
+ metadata.
81
+ fetch("resources", []).
82
+ find { |r| r.fetch("@type") == "PackageBaseAddress/3.0.0" }&.
83
+ fetch("@id")
84
+ end
85
+
86
+ def search_url_from_v3_metadata(metadata)
87
+ metadata.
88
+ fetch("resources", []).
89
+ find { |r| r.fetch("@type") == "SearchQueryService" }&.
90
+ fetch("@id")
91
+ end
92
+
93
+ def build_v2_url(response, repo_details)
94
+ doc = Nokogiri::XML(response.body)
95
+ doc.remove_namespaces!
96
+ base_url = doc.at_xpath("service")&.attributes&.fetch("base")&.value
97
+ return unless base_url
98
+
99
+ {
100
+ repository_url: base_url,
101
+ versions_url: File.join(
102
+ base_url,
103
+ "FindPackagesById()?id='#{dependency.name}'"
104
+ ),
105
+ auth_header: auth_header_for_token(repo_details.fetch(:token)),
106
+ repository_type: "v2"
107
+ }
108
+ end
109
+
110
+ def check_repo_reponse(response, details)
111
+ return unless [401, 402, 403].include?(response.status)
112
+ raise if details.fetch(:url) == DEFAULT_REPOSITORY_URL
113
+
114
+ raise PrivateSourceAuthenticationFailure, details.fetch(:url)
115
+ end
116
+
117
+ def handle_timeout(repo_metadata_url:)
118
+ raise if repo_metadata_url == DEFAULT_REPOSITORY_URL
119
+
120
+ raise PrivateSourceTimedOut, repo_metadata_url
121
+ end
122
+
123
+ def known_repositories
124
+ return @known_repositories if @known_repositories
125
+
126
+ @known_repositories = []
127
+ @known_repositories += credential_repositories
128
+ @known_repositories += config_file_repositories
129
+
130
+ if @known_repositories.empty?
131
+ @known_repositories << { url: DEFAULT_REPOSITORY_URL, token: nil }
132
+ end
133
+
134
+ @known_repositories.uniq
135
+ end
136
+
137
+ def credential_repositories
138
+ @credential_repositories ||=
139
+ credentials.
140
+ select { |cred| cred["type"] == "nuget_feed" }.
141
+ map { |c| { url: c.fetch("url"), token: c["token"] } }
142
+ end
143
+
144
+ def config_file_repositories
145
+ return [] unless config_file
146
+
147
+ doc = Nokogiri::XML(config_file.content)
148
+ doc.remove_namespaces!
149
+ sources =
150
+ doc.css("configuration > packageSources > add").map do |node|
151
+ {
152
+ key:
153
+ node.attribute("key")&.value&.strip ||
154
+ node.at_xpath("./key")&.content&.strip,
155
+ url:
156
+ node.attribute("value")&.value&.strip ||
157
+ node.at_xpath("./value")&.content&.strip
158
+ }
159
+ end
160
+
161
+ sources.reject! do |s|
162
+ known_urls = credential_repositories.map { |cr| cr.fetch(:url) }
163
+ known_urls.include?(s.fetch(:url))
164
+ end
165
+
166
+ add_config_file_credentials(sources: sources, doc: doc)
167
+ sources.each { |details| details.delete(:key) }
168
+
169
+ sources
170
+ end
171
+
172
+ def default_repository_details
173
+ {
174
+ repository_url: DEFAULT_REPOSITORY_URL,
175
+ versions_url: "https://api.nuget.org/v3-flatcontainer/"\
176
+ "#{dependency.name.downcase}/index.json",
177
+ search_url: "https://api-v2v3search-0.nuget.org/query"\
178
+ "?q=#{dependency.name.downcase}&prerelease=true",
179
+ auth_header: {},
180
+ repository_type: "v3"
181
+ }
182
+ end
183
+
184
+ def add_config_file_credentials(sources:, doc:)
185
+ sources.each do |source_details|
186
+ key = source_details.fetch(:key)
187
+ next source_details[:token] = nil unless key
188
+ next source_details[:token] = nil if key.match?(/^\d/)
189
+
190
+ tag = key.gsub(" ", "_x0020_")
191
+ creds_nodes = doc.css("configuration > packageSourceCredentials "\
192
+ "> #{tag} > add")
193
+
194
+ username =
195
+ creds_nodes.
196
+ find { |n| n.attribute("key")&.value == "Username" }&.
197
+ attribute("value")&.value
198
+ password =
199
+ creds_nodes.
200
+ find { |n| n.attribute("key")&.value == "ClearTextPassword" }&.
201
+ attribute("value")&.value
202
+
203
+ # Note: We have to look for plain text passwords, as we have no
204
+ # way of decrypting encrypted passwords. For the same reason we
205
+ # don't fetch API keys from the nuget.config at all.
206
+ next source_details[:token] = nil unless username && password
207
+
208
+ source_details[:token] = "#{username}:#{password}"
209
+ end
210
+
211
+ sources
212
+ end
213
+
214
+ def auth_header_for_token(token)
215
+ return {} unless token
216
+
217
+ if token.include?(":")
218
+ encoded_token = Base64.encode64(token).delete("\n")
219
+ { "Authorization" => "Basic #{encoded_token}" }
220
+ elsif Base64.decode64(token).ascii_only? &&
221
+ Base64.decode64(token).include?(":")
222
+ { "Authorization" => "Basic #{token.delete("\n")}" }
223
+ else
224
+ { "Authorization" => "Bearer #{token}" }
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ #######################################################################
4
+ # For more details on Dotnet version constraints, see: #
5
+ # https://docs.microsoft.com/en-us/nuget/reference/package-versioning #
6
+ #######################################################################
7
+
8
+ require "dependabot/nuget/update_checker"
9
+ require "dependabot/nuget/version"
10
+
11
+ module Dependabot
12
+ module Nuget
13
+ class UpdateChecker
14
+ class RequirementsUpdater
15
+ VERSION_REGEX = /[0-9a-zA-Z]+(?:\.[a-zA-Z0-9\-]+)*/.freeze
16
+
17
+ def initialize(requirements:, latest_version:, source_details:)
18
+ @requirements = requirements
19
+ @source_details = source_details
20
+ return unless latest_version
21
+
22
+ @latest_version = version_class.new(latest_version)
23
+ end
24
+
25
+ def updated_requirements
26
+ return requirements unless latest_version
27
+
28
+ # Note: Order is important here. The FileUpdater needs the updated
29
+ # requirement at index `i` to correspond to the previous requirement
30
+ # at the same index.
31
+ requirements.map do |req|
32
+ next req if req.fetch(:requirement).nil?
33
+ next req if req.fetch(:requirement).include?(",")
34
+
35
+ new_req =
36
+ if req.fetch(:requirement).include?("*")
37
+ update_wildcard_requirement(req.fetch(:requirement))
38
+ else
39
+ # Since range requirements are excluded by the line above we
40
+ # can just do a `gsub` on anything that looks like a version
41
+ req[:requirement].gsub(VERSION_REGEX, latest_version.to_s)
42
+ end
43
+
44
+ next req if new_req == req.fetch(:requirement)
45
+
46
+ req.merge(requirement: new_req, source: updated_source)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :requirements, :latest_version, :source_details
53
+
54
+ def version_class
55
+ Nuget::Version
56
+ end
57
+
58
+ def update_wildcard_requirement(req_string)
59
+ precision = req_string.split("*").first.split(/\.|\-/).count
60
+ wilcard_section = req_string.partition(/(?=[.\-]\*)/).last
61
+
62
+ version_parts = latest_version.segments.first(precision)
63
+ version = version_parts.join(".")
64
+
65
+ version + wilcard_section
66
+ end
67
+
68
+ def updated_source
69
+ {
70
+ type: "nuget_repo",
71
+ url: source_details.fetch(:repo_url),
72
+ nuspec_url: source_details.fetch(:nuspec_url),
73
+ source_url: source_details.fetch(:source_url)
74
+ }
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "excon"
4
+ require "nokogiri"
5
+
6
+ require "dependabot/nuget/version"
7
+ require "dependabot/nuget/requirement"
8
+ require "dependabot/nuget/update_checker"
9
+ require "dependabot/shared_helpers"
10
+
11
+ module Dependabot
12
+ module Nuget
13
+ class UpdateChecker
14
+ class VersionFinder
15
+ require_relative "repository_finder"
16
+
17
+ def initialize(dependency:, dependency_files:, credentials:,
18
+ ignored_versions: [])
19
+ @dependency = dependency
20
+ @dependency_files = dependency_files
21
+ @credentials = credentials
22
+ @ignored_versions = ignored_versions
23
+ end
24
+
25
+ def latest_version_details
26
+ @latest_version_details ||=
27
+ begin
28
+ tmp_versions = versions
29
+ unless wants_prerelease?
30
+ tmp_versions.reject! { |d| d.fetch(:version).prerelease? }
31
+ end
32
+ tmp_versions.reject! do |hash|
33
+ ignore_reqs.any? { |r| r.satisfied_by?(hash.fetch(:version)) }
34
+ end
35
+ tmp_versions.max_by { |hash| hash.fetch(:version) }
36
+ end
37
+ end
38
+
39
+ def versions
40
+ available_v3_versions + available_v2_versions
41
+ end
42
+
43
+ attr_reader :dependency, :dependency_files, :credentials,
44
+ :ignored_versions
45
+
46
+ private
47
+
48
+ def available_v3_versions
49
+ v3_nuget_listings.flat_map do |listing|
50
+ listing.
51
+ fetch("versions", []).
52
+ map do |v|
53
+ nuspec_url =
54
+ listing.fetch("listing_details").
55
+ fetch(:versions_url).
56
+ gsub(/index\.json$/, "#{v}/#{sanitized_name}.nuspec")
57
+
58
+ {
59
+ version: version_class.new(v),
60
+ nuspec_url: nuspec_url,
61
+ source_url: nil,
62
+ repo_url:
63
+ listing.fetch("listing_details").fetch(:repository_url)
64
+ }
65
+ end
66
+ end
67
+ end
68
+
69
+ def available_v2_versions
70
+ v2_nuget_listings.flat_map do |listing|
71
+ body = listing.fetch("xml_body", [])
72
+ doc = Nokogiri::XML(body)
73
+ doc.remove_namespaces!
74
+
75
+ doc.xpath("/feed/entry").map do |entry|
76
+ listed = entry.at_xpath("./properties/Listed")&.content&.strip
77
+ next if listed&.casecmp("false")&.zero?
78
+
79
+ entry_details = dependency_details_from_v2_entry(entry)
80
+ entry_details.merge(
81
+ repo_url: listing.fetch("listing_details").
82
+ fetch(:repository_url)
83
+ )
84
+ end.compact
85
+ end
86
+ end
87
+
88
+ def dependency_details_from_v2_entry(entry)
89
+ version = entry.at_xpath("./properties/Version").content.strip
90
+ source_urls = []
91
+ [
92
+ entry.at_xpath("./properties/ProjectUrl").content,
93
+ entry.at_xpath("./properties/ReleaseNotes").content
94
+ ].join(" ").scan(Source::SOURCE_REGEX) do
95
+ source_urls << Regexp.last_match.to_s
96
+ end
97
+
98
+ source_url = source_urls.find { |url| Source.from_url(url) }
99
+ source_url = Source.from_url(source_url)&.url if source_url
100
+
101
+ {
102
+ version: version_class.new(version),
103
+ nuspec_url: nil,
104
+ source_url: source_url
105
+ }
106
+ end
107
+
108
+ def wants_prerelease?
109
+ if dependency.version &&
110
+ version_class.correct?(dependency.version) &&
111
+ version_class.new(dependency.version).prerelease?
112
+ return true
113
+ end
114
+
115
+ dependency.requirements.any? do |req|
116
+ reqs = (req.fetch(:requirement) || "").split(",").map(&:strip)
117
+ reqs.any? { |r| r.include?("-") }
118
+ end
119
+ end
120
+
121
+ def v3_nuget_listings
122
+ return @v3_nuget_listings unless @v3_nuget_listings.nil?
123
+
124
+ dependency_urls.
125
+ select { |details| details.fetch(:repository_type) == "v3" }.
126
+ map do |url_details|
127
+ versions = versions_for_v3_repository(url_details)
128
+ next unless versions
129
+
130
+ { "versions" => versions, "listing_details" => url_details }
131
+ end.compact
132
+ end
133
+
134
+ def v2_nuget_listings
135
+ return @v2_nuget_listings unless @v2_nuget_listings.nil?
136
+
137
+ dependency_urls.
138
+ select { |details| details.fetch(:repository_type) == "v2" }.
139
+ map do |url_details|
140
+ response = Excon.get(
141
+ url_details[:versions_url],
142
+ headers: url_details[:auth_header],
143
+ idempotent: true,
144
+ **excon_defaults
145
+ )
146
+ next unless response.status == 200
147
+
148
+ {
149
+ "xml_body" => response.body,
150
+ "listing_details" => url_details
151
+ }
152
+ end.compact
153
+ end
154
+
155
+ def versions_for_v3_repository(repository_details)
156
+ # If we have a search URL we use it (since it will exclude unlisted
157
+ # versions)
158
+ if repository_details[:search_url]
159
+ response = Excon.get(
160
+ repository_details[:search_url],
161
+ headers: repository_details[:auth_header],
162
+ idempotent: true,
163
+ **excon_defaults
164
+ )
165
+ return unless response.status == 200
166
+
167
+ JSON.parse(response.body).fetch("data").
168
+ find { |d| d.fetch("id").casecmp(sanitized_name).zero? }&.
169
+ fetch("versions")&.
170
+ map { |d| d.fetch("version") }
171
+ # Otherwise, use the versions URL
172
+ elsif repository_details[:versions_url]
173
+ response = Excon.get(
174
+ repository_details[:versions_url],
175
+ headers: repository_details[:auth_header],
176
+ idempotent: true,
177
+ **excon_defaults
178
+ )
179
+ return unless response.status == 200
180
+
181
+ JSON.parse(response.body).fetch("versions")
182
+ end
183
+ end
184
+
185
+ def dependency_urls
186
+ @dependency_urls ||=
187
+ RepositoryFinder.new(
188
+ dependency: dependency,
189
+ credentials: credentials,
190
+ config_file: nuget_config
191
+ ).dependency_urls
192
+ end
193
+
194
+ def ignore_reqs
195
+ ignored_versions.map { |req| requirement_class.new(req.split(",")) }
196
+ end
197
+
198
+ def nuget_config
199
+ @nuget_config ||=
200
+ dependency_files.find { |f| f.name.casecmp("nuget.config").zero? }
201
+ end
202
+
203
+ def sanitized_name
204
+ dependency.name.downcase
205
+ end
206
+
207
+ def version_class
208
+ Nuget::Version
209
+ end
210
+
211
+ def requirement_class
212
+ Nuget::Requirement
213
+ end
214
+
215
+ def excon_defaults
216
+ # For large JSON files we sometimes need a little longer than for
217
+ # other languages. For example, see:
218
+ # https://dotnet.myget.org/F/aspnetcore-dev/api/v3/query?
219
+ # q=microsoft.aspnetcore.mvc&prerelease=true
220
+ SharedHelpers.excon_defaults.merge(
221
+ connect_timeout: 10,
222
+ write_timeout: 10,
223
+ read_timeout: 10
224
+ )
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end