secupdater 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3e2a3d2fd687a912cd17a2dab8216d5c7187693c5239ccfdeac56ce87a825ac
4
- data.tar.gz: 6db3dd9e3f87b76f96e75b3849c9d447ac24a1f0ea31972d24a37cdc20437ee6
3
+ metadata.gz: 4f19feed6401e9c35fd600135fcd76372d6f2b1146648996958119ac0063d5e3
4
+ data.tar.gz: 7fa86bc7f193bbe245dd672f9b1b362e3227917ebd92a78e28792cd810dbae4d
5
5
  SHA512:
6
- metadata.gz: 66aabee38119a94edf9f609ba05d6d8c59581a7994f3f8c3a089f836c8460af124a5a34b08f44418dd9588439b590204864970fad3faf492e05a885f605ae329
7
- data.tar.gz: 3f8607eca5779819b70d6346365dfa70ec158af70bc9f5c8a3d6c2c3ea9bef3a53839708c9eee388ff942369cc5303c03af958db07013dfb9d85ec04d162155a
6
+ metadata.gz: c0577d54657596f3fd338660330dde5da2dbee749a2c1349570ee64706583663850b4c080b8caccb275e63e1bd439ac3576ae7b43570498f1d7f4f82de3339ca
7
+ data.tar.gz: 4e07f424812cd76787ffd12226fc15a26c1c2ccc2c1b1075da706e28d3496f9acaccfc39a90b1a48910502723355a2e6b28be7910c7f6c4ad7fb48143406c86c
@@ -68,7 +68,7 @@ module Secupdater
68
68
  advisory_id = advisory["id"] if advisory_id.empty?
69
69
 
70
70
  SecurityAdvisory.new(result["gem"]["name"], advisory_id, advisory["patched_versions"])
71
- end
71
+ end.uniq # bundle-audit reports an advisory once per platform the gem is locked for
72
72
  end
73
73
 
74
74
  def satisfies_requirement?(version, requirement)
@@ -29,9 +29,12 @@ module Secupdater
29
29
 
30
30
  if branch_exists?(http)
31
31
  others = other_authors(http)
32
- return [:skipped, "#{reference} has commits from #{others.join(", ")}, so it won't be updated"] if others.any?
32
+ if others.any?
33
+ explain_skipping(http, open_merge_request, others) if open_merge_request
34
+ return [:skipped, "#{reference} has commits from #{others.join(", ")}, so it won't be updated"]
35
+ end
33
36
 
34
- up_to_date = branch_has_changes?(http)
37
+ up_to_date = branch_has_changes?(http) && !target_manifests_moved_on?(http)
35
38
  commit(http, force: true) unless up_to_date
36
39
  else
37
40
  commit(http)
@@ -39,7 +42,12 @@ module Secupdater
39
42
 
40
43
  if open_merge_request
41
44
  remember(open_merge_request)
42
- return [:up_to_date, nil] if up_to_date
45
+
46
+ # e.g. when this update has since absorbed another dependency's advisories
47
+ described = open_merge_request["description"] == description
48
+ request(http, Net::HTTP::Put, "merge_requests/#{open_merge_request["iid"]}", { description: description }) unless described
49
+
50
+ return [:up_to_date, nil] if up_to_date && described
43
51
  else
44
52
  remember(JSON.parse(create_merge_request(http).body))
45
53
  end
@@ -84,8 +92,12 @@ module Secupdater
84
92
  request(http, Net::HTTP::Post, "repository/commits", data)
85
93
  end
86
94
 
95
+ # Against every dependency the MR fixes advisories for, so finish closes
96
+ # their own MRs once another update has absorbed them
87
97
  def remember(merge_request)
88
- @current_merge_requests[bump.dependency.name] = merge_request["iid"]
98
+ [bump.dependency.name, *advisories.map(&:name)].uniq.each do
99
+ @current_merge_requests[it] = merge_request["iid"]
100
+ end
89
101
  end
90
102
 
91
103
  def find_open_secupdater_merge_requests(http)
@@ -93,6 +105,26 @@ module Secupdater
93
105
  JSON.parse(request(http, Net::HTTP::Get, "merge_requests?#{query}").body)
94
106
  end
95
107
 
108
+ # Hidden in the rendered comment; marks the comment so it's only posted once
109
+ SKIPPING_MARKER = "<!-- secupdater:skipping -->"
110
+
111
+ # Says on the MR, once, that secupdater has stopped updating it, so whoever
112
+ # committed knows rebasing and refreshing it is now up to them
113
+ def explain_skipping(http, merge_request, others)
114
+ notes_path = "merge_requests/#{merge_request["iid"]}/notes"
115
+ notes = JSON.parse(request(http, Net::HTTP::Get, "#{notes_path}?#{URI.encode_www_form(per_page: 100)}").body)
116
+ return if notes.any? { it.dig("author", "id") == own_user(http)["id"] && it["body"].to_s.include?(SKIPPING_MARKER) }
117
+
118
+ body = <<~BODY.chomp
119
+ #{SKIPPING_MARKER}
120
+ This branch has commits from #{others.join(", ")}, so secupdater won't update it any more, \
121
+ including rebasing it when `#{target_branch}` changes.
122
+
123
+ To hand it back, delete the branch, or force-push it without those commits, and secupdater will take it over on its next run.
124
+ BODY
125
+ request(http, Net::HTTP::Post, notes_path, { body: })
126
+ end
127
+
96
128
  def close_merge_request(http, iid, reason)
97
129
  request(http, Net::HTTP::Post, "merge_requests/#{iid}/notes", { body: reason })
98
130
  request(http, Net::HTTP::Put, "merge_requests/#{iid}", { state_event: "close" })
@@ -129,10 +161,12 @@ module Secupdater
129
161
  end
130
162
 
131
163
  def own_emails(http)
132
- @own_emails ||= begin
133
- user = JSON.parse(api_request(http, Net::HTTP::Get, "user").body)
134
- user.values_at("email", "commit_email", "public_email").reject { it.nil? || it.empty? }
135
- end
164
+ own_user(http).values_at("email", "commit_email", "public_email").reject { it.nil? || it.empty? }
165
+ end
166
+
167
+ # Whoever owns the access token
168
+ def own_user(http)
169
+ @own_user ||= JSON.parse(api_request(http, Net::HTTP::Get, "user").body)
136
170
  end
137
171
 
138
172
  def branch_has_changes?(http)
@@ -143,6 +177,17 @@ module Secupdater
143
177
  end
144
178
  end
145
179
 
180
+ # Whether a manifest has changed on the target branch since the branch was
181
+ # cut from it. The update can come out the same even then (e.g. when another
182
+ # MR made some of the same bumps), but the branch would show changes the
183
+ # target already has, and may conflict.
184
+ def target_manifests_moved_on?(http)
185
+ query = URI.encode_www_form(from: reference, to: start_sha)
186
+ diffs = JSON.parse(request(http, Net::HTTP::Get, "repository/compare?#{query}").body)["diffs"]
187
+
188
+ diffs.any? { ecosystem.manifest_paths.intersect?(it.values_at("old_path", "new_path")) }
189
+ end
190
+
146
191
  def request(http, type, path, data = nil, allow_missing: false)
147
192
  api_request(http, type, "projects/#{URI.encode_www_form_component(project_id)}/#{path}", data, allow_missing:)
148
193
  end
@@ -30,7 +30,10 @@ module Secupdater
30
30
 
31
31
  def description
32
32
  heading = "Upgrade #{ecosystem.ecosystem_name} dependency #{bump.dependency.name} to version #{bump.to} to address the following security advisories:"
33
- items = advisories.map { "* #{it.report_id}" }.join("\n")
33
+ # Advisories for other dependencies this update also fixes say which one
34
+ items = advisories.map do |advisory|
35
+ advisory.name == bump.dependency.name ? "* #{advisory.report_id}" : "* #{advisory.report_id} (#{advisory.name})"
36
+ end.join("\n")
34
37
  "#{heading}\n\n#{items}\n"
35
38
  end
36
39
 
@@ -1,5 +1,12 @@
1
1
  module Secupdater
2
2
  class Update
3
+ # What updating one vulnerable dependency on its own came to, before anything
4
+ # is published. diff is every dependency the update moved, not just this one.
5
+ Candidate = Struct.new(:dependency, :advisories, :status, :message, :level, :bump, :diff, :file_changes, keyword_init: true) do
6
+ def name = dependency.name
7
+ def resolved? = status == :resolved
8
+ end
9
+
3
10
  attr_reader :logger, :ecosystem, :publisher, :original
4
11
 
5
12
  def initialize(logger, ecosystem, publisher: nil)
@@ -16,75 +23,148 @@ module Secupdater
16
23
  missing = deps_needing_checking.keys - original.keys
17
24
  raise Secupdater::Error, "Advisories for dependencies not in the lockfile: #{missing.join(', ')}" if missing.any?
18
25
 
19
- results = deps_needing_checking.to_h do |name, dep_advisories|
20
- [name, update_dependency(original[name], dep_advisories)]
26
+ candidates = deps_needing_checking.to_h do |name, dep_advisories|
27
+ [name, try_update(original[name], dep_advisories)]
21
28
  end
22
29
 
30
+ results = publish(candidates)
31
+
23
32
  publisher&.finish(deps_needing_checking.keys)&.each { logger.info it }
24
33
 
25
- results
34
+ deps_needing_checking.keys.to_h { [it, results.fetch(it)] }
26
35
  end
27
36
 
28
- def update_dependency(dep, advisories)
29
- logger.info("starting update for #{dep.name}, #{advisories.length} advisories")
37
+ private
30
38
 
31
- status = message = nil
39
+ def try_update(dep, advisories)
40
+ logger.info("starting update for #{dep.name}, #{advisories.length} advisories")
32
41
 
33
42
  levels = ecosystem.update_levels
43
+ candidate = nil
34
44
 
35
45
  levels.each do |level|
36
- status, message = attempt_update(dep, advisories, level)
37
- return status unless message
46
+ candidate = attempt_update(dep, advisories, level)
47
+ return candidate if candidate.resolved?
38
48
 
39
- logger.debug "#{level} update: #{message}" unless level == levels.last
49
+ logger.debug "#{level} update: #{candidate.message}" unless level == levels.last
40
50
  end
41
51
 
42
- if status == :failed
43
- logger.error message
44
- else
45
- logger.warn message
46
- end
47
-
48
- status
52
+ candidate
49
53
  rescue Secupdater::UnretryableUpdateFailed => e
50
- logger.error "#{dep.name}: #{e.message}\n#{e.output}"
51
- :failed
52
- rescue Secupdater::PublishFailed => e
53
- logger.error "#{dep.name}: #{e.message}"
54
- :failed
54
+ Candidate.new(dependency: dep, advisories:, status: :failed, message: "#{dep.name}: #{e.message}\n#{e.output}")
55
55
  end
56
56
 
57
- private
58
-
59
57
  def attempt_update(dep, advisories, level)
60
58
  file_changes = ecosystem.try_update(dep.name, level:)
61
- bump = ecosystem.diff_snapshot(original, ecosystem.snapshot).detect { it.dependency.name == dep.name }
59
+ diff = ecosystem.diff_snapshot(original, ecosystem.snapshot)
60
+ bump = diff.detect { it.dependency.name == dep.name }
61
+ attempt = { dependency: dep, advisories:, level:, bump:, diff:, file_changes: }
62
62
 
63
- return [:unchanged, "#{dep.name} didn't update (stuck at #{dep.version})"] unless bump
63
+ return Candidate.new(**attempt, status: :unchanged, message: "#{dep.name} didn't update (stuck at #{dep.version})") unless bump
64
64
 
65
- resolved = advisories.all? do |advisory|
66
- advisory.patched_versions.any? { ecosystem.satisfies_requirement?(bump.to, it) }
65
+ unless fixes?(bump, advisories)
66
+ return Candidate.new(**attempt, status: :unresolved, message: "#{dep.name} didn't update to a version that resolves the security warnings (went from #{bump.from} to #{bump.to})")
67
+ end
68
+
69
+ Candidate.new(**attempt, status: :resolved)
70
+ rescue Secupdater::UnretryableUpdateFailed
71
+ raise
72
+ rescue Secupdater::UpdateCommandFailed => e
73
+ Candidate.new(dependency: dep, advisories:, level:, status: :failed, message: "#{dep.name}: #{e.message}\n#{e.output}")
74
+ ensure
75
+ restore(file_changes)
76
+ end
77
+
78
+ # Publishes each update, with the advisories of any dependencies it absorbs.
79
+ # If an absorbing update is declined, the dependencies it absorbed are
80
+ # published without it (absorbing each other where they can).
81
+ def publish(candidates)
82
+ absorbed = absorptions(candidates)
83
+ results = {}
84
+
85
+ candidates.each_value do |candidate|
86
+ next if absorbed.key?(candidate.name)
87
+
88
+ covered = absorbed.filter_map { |name, absorber| candidates[name] if absorber == candidate.name }
89
+ results[candidate.name] = publish_candidate(candidate, covered)
90
+ next if covered.empty?
91
+
92
+ if results[candidate.name] == :declined
93
+ logger.info "#{candidate.name}'s update was declined, so updating #{covered.map(&:name).join(', ')} without it"
94
+ results.merge!(publish(covered.to_h { [it.name, it] }))
95
+ else
96
+ covered.each { results[it.name] = :absorbed }
97
+ end
67
98
  end
68
99
 
69
- unless resolved
70
- return [:unresolved, "#{dep.name} didn't update to a version that resolves the security warnings (went from #{bump.from} to #{bump.to})"]
100
+ results
101
+ end
102
+
103
+ # Which dependencies' updates go into another's MR, as { absorbed => absorber }.
104
+ # A absorbs B when A's update also moves B to a version that fixes all of B's
105
+ # advisories. The updates that cover the most go first, then by name so runs
106
+ # agree, and each dependency ends up in only one MR.
107
+ def absorptions(candidates)
108
+ resolved = candidates.values.select(&:resolved?)
109
+ covers = resolved.to_h do |candidate|
110
+ [candidate.name, candidates.values.select { it != candidate && covers?(candidate, it) }.map(&:name)]
111
+ end
112
+
113
+ absorbed = {}
114
+ covers.sort_by { |name, names| [-names.length, name] }.each do |name, names|
115
+ next if absorbed.key?(name)
116
+
117
+ names.each do |other|
118
+ absorbed[other] = name unless absorbed.key?(other) || absorbed.value?(other)
119
+ end
71
120
  end
121
+ absorbed
122
+ end
72
123
 
73
- advisories.each do |advisory|
74
- logger.info "upgrading #{dep.name} from #{bump.from} to #{bump.to} (#{level}) would resolve #{advisory}"
124
+ def covers?(candidate, other)
125
+ bump = candidate.diff.detect { it.dependency.name == other.name }
126
+ bump && (bump.to.nil? || fixes?(bump, other.advisories))
127
+ end
128
+
129
+ def fixes?(bump, advisories)
130
+ advisories.all? do |advisory|
131
+ advisory.patched_versions.any? { ecosystem.satisfies_requirement?(bump.to, it) }
132
+ end
133
+ end
134
+
135
+ def publish_candidate(candidate, covered)
136
+ unless candidate.resolved?
137
+ if candidate.status == :failed
138
+ logger.error candidate.message
139
+ else
140
+ logger.warn candidate.message
141
+ end
142
+ return candidate.status
75
143
  end
76
144
 
77
- return [:resolved, nil] unless publisher
145
+ log_resolution(candidate, covered)
146
+
147
+ return :resolved unless publisher
78
148
 
79
- status, message = publisher.call(ecosystem, bump, advisories, file_changes)
149
+ status, message = publisher.call(ecosystem, candidate.bump, candidate.advisories + covered.flat_map(&:advisories), candidate.file_changes)
80
150
  logger.warn message if message
81
- [status, nil]
82
- rescue Secupdater::UnretryableUpdateFailed
83
- raise
84
- rescue Secupdater::UpdateCommandFailed => e
85
- [:failed, "#{dep.name}: #{e.message}\n#{e.output}"]
86
- ensure
87
- restore(file_changes)
151
+ status
152
+ rescue Secupdater::PublishFailed => e
153
+ logger.error "#{candidate.name}: #{e.message}"
154
+ :failed
155
+ end
156
+
157
+ def log_resolution(candidate, covered)
158
+ bump = candidate.bump
159
+ upgrade = "upgrading #{candidate.name} from #{bump.from} to #{bump.to} (#{candidate.level})"
160
+
161
+ candidate.advisories.each { logger.info "#{upgrade} would resolve #{it}" }
162
+
163
+ covered.each do |other|
164
+ other_bump = candidate.diff.detect { it.dependency.name == other.name }
165
+ moves = other_bump.to ? "moves #{other.name} from #{other_bump.from} to #{other_bump.to}" : "removes #{other.name}"
166
+ other.advisories.each { logger.info "#{upgrade} #{moves}, which would resolve #{it}" }
167
+ end
88
168
  end
89
169
 
90
170
  def restore(file_changes)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Secupdater
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -75,6 +75,21 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
75
75
  expect(rubygems.parse_advisories(StringIO.new(report.to_json)).map(&:report_id)).to eq(["RUBYSEC-2024-1"])
76
76
  end
77
77
  end
78
+
79
+ context "the same advisory reported once per platform the gem is locked for" do
80
+ let(:result) do
81
+ {
82
+ type: "unpatched_gem",
83
+ gem: { name: "nokogiri", version: "1.19.4" },
84
+ advisory: { id: "GHSA-3w22-6jfg-527w", ghsa: "3w22-6jfg-527w", patched_versions: [">= 1.19.5"] }
85
+ }
86
+ end
87
+ let(:report) { { results: [result, result] } }
88
+
89
+ it "returns it once" do
90
+ expect(rubygems.parse_advisories(StringIO.new(report.to_json)).map(&:report_id)).to eq(["ghsa/3w22-6jfg-527w"])
91
+ end
92
+ end
78
93
  end
79
94
 
80
95
  describe "#snapshot" do
@@ -53,9 +53,17 @@ RSpec.describe Secupdater::Publisher::Gitlab do
53
53
  .to_return(status: 200, body: { commits: branch_commits }.to_json)
54
54
  end
55
55
 
56
+ let(:target_diffs_since_branch) { [] }
57
+
58
+ let!(:target_moved_on_stub) do
59
+ stub_request(:get, "#{project_api}/repository/compare")
60
+ .with(query: { from: "security/css_parser-1.1.0", to: "abc123" })
61
+ .to_return(status: 200, body: { commits: [], diffs: target_diffs_since_branch }.to_json)
62
+ end
63
+
56
64
  let!(:user_stub) do
57
65
  stub_request(:get, "#{api}/user")
58
- .to_return(status: 200, body: { email: bot_email, commit_email: bot_email, public_email: "" }.to_json)
66
+ .to_return(status: 200, body: { id: 42, email: bot_email, commit_email: bot_email, public_email: "" }.to_json)
59
67
  end
60
68
 
61
69
  let!(:branch_file_stub) do
@@ -101,6 +109,30 @@ RSpec.describe Secupdater::Publisher::Gitlab do
101
109
  end
102
110
  end
103
111
 
112
+ context "with advisories for other gems the update also fixes" do
113
+ let(:advisories) do
114
+ [
115
+ Secupdater::SecurityAdvisory.new("css_parser", "cve/2024-0001", [">= 1.1.0"]),
116
+ Secupdater::SecurityAdvisory.new("addressable", "ghsa/abcd-efgh", [">= 2.9.0"])
117
+ ]
118
+ end
119
+
120
+ let!(:create_mr_stub) do
121
+ stub_request(:post, "#{project_api}/merge_requests")
122
+ .with(body: hash_including(
123
+ source_branch: "security/css_parser-1.1.0",
124
+ description: "Upgrade RubyGems dependency css_parser to version 1.1.0 to address the following security advisories:\n\n* cve/2024-0001\n* ghsa/abcd-efgh (addressable)\n"
125
+ ))
126
+ .to_return(status: 201, body: { iid: 9 }.to_json)
127
+ end
128
+
129
+ it "names the other gem against its advisories" do
130
+ publish
131
+
132
+ expect(create_mr_stub).to have_been_requested
133
+ end
134
+ end
135
+
104
136
  context "deleting a file" do
105
137
  let(:changes) { [Secupdater::FileChange.new("Gemfile.lock", "delete", nil, "xyz")] }
106
138
 
@@ -172,17 +204,56 @@ RSpec.describe Secupdater::Publisher::Gitlab do
172
204
  end
173
205
 
174
206
  context "an open MR, labelled or not" do
175
- let(:existing_merge_requests) { [{ iid: 7, state: "opened", labels: [] }, { iid: 3, state: "closed", labels: ["secupdater"] }] }
207
+ let(:description) { "Upgrade RubyGems dependency css_parser to version 1.1.0 to address the following security advisories:\n\n* cve/2024-0001\n" }
208
+ let(:existing_merge_requests) { [{ iid: 7, state: "opened", labels: [], description: }, { iid: 3, state: "closed", labels: ["secupdater"] }] }
176
209
  let(:branch_exists) { true }
177
210
 
211
+ let!(:update_description_stub) do
212
+ stub_request(:put, "#{project_api}/merge_requests/7")
213
+ .with(body: { description: "Upgrade RubyGems dependency css_parser to version 1.1.0 to address the following security advisories:\n\n* cve/2024-0001\n" }, headers: headers)
214
+ .to_return(status: 200, body: "{}")
215
+ end
216
+
178
217
  context "whose branch already has these changes" do
179
218
  let(:branch_lockfile) { "abc" }
180
219
 
220
+ context "but whose description lists different advisories (e.g. the update has since absorbed another gem's)" do
221
+ let(:description) { "Upgrade RubyGems dependency css_parser to version 1.1.0 to address the following security advisories:\n\n* cve/2023-9999\n" }
222
+
223
+ it "updates the description, without committing" do
224
+ expect(publish).to eq([:published, nil])
225
+
226
+ expect(update_description_stub).to have_been_requested
227
+ expect(a_request(:post, "#{project_api}/repository/commits")).not_to have_been_made
228
+ end
229
+ end
230
+
181
231
  it "leaves it alone" do
182
232
  expect(publish).to eq([:up_to_date, nil])
183
233
 
184
234
  expect(a_request(:post, //)).not_to have_been_made
185
235
  end
236
+
237
+ context "but main has changed other files since the branch was cut" do
238
+ let(:target_diffs_since_branch) { [{ old_path: "app/models/user.rb", new_path: "app/models/user.rb" }] }
239
+
240
+ it "leaves it alone" do
241
+ expect(publish).to eq([:up_to_date, nil])
242
+
243
+ expect(a_request(:post, //)).not_to have_been_made
244
+ end
245
+ end
246
+
247
+ context "but main's Gemfile.lock has changed since the branch was cut (e.g. another MR made some of the same bumps)" do
248
+ let(:target_diffs_since_branch) { [{ old_path: "Gemfile.lock", new_path: "Gemfile.lock" }] }
249
+
250
+ it "replaces the branch's commit, from start_sha, so it doesn't show changes main already has" do
251
+ expect(publish).to eq([:published, nil])
252
+
253
+ expect(force_commit_stub).to have_been_requested
254
+ expect(create_mr_stub).not_to have_been_requested
255
+ end
256
+ end
186
257
  end
187
258
 
188
259
  context "whose branch is out of date" do
@@ -202,10 +273,72 @@ RSpec.describe Secupdater::Publisher::Gitlab do
202
273
  ]
203
274
  end
204
275
 
276
+ let(:existing_notes) { [] }
277
+
278
+ let!(:notes_stub) do
279
+ stub_request(:get, "#{project_api}/merge_requests/7/notes")
280
+ .with(query: { per_page: "100" }, headers: headers)
281
+ .to_return(status: 200, body: existing_notes.to_json)
282
+ end
283
+
284
+ let!(:comment_stub) do
285
+ stub_request(:post, "#{project_api}/merge_requests/7/notes")
286
+ .with(body: { body: <<~BODY.chomp }, headers: headers)
287
+ <!-- secupdater:skipping -->
288
+ This branch has commits from Jo Bloggs, so secupdater won't update it any more, including rebasing it when `main` changes.
289
+
290
+ To hand it back, delete the branch, or force-push it without those commits, and secupdater will take it over on its next run.
291
+ BODY
292
+ .to_return(status: 201, body: "{}")
293
+ end
294
+
205
295
  it "leaves the branch alone and says why" do
206
296
  expect(publish).to eq([:skipped, "security/css_parser-1.1.0 has commits from Jo Bloggs, so it won't be updated"])
207
297
 
208
- expect(a_request(:post, //)).not_to have_been_made
298
+ expect(a_request(:post, "#{project_api}/repository/commits")).not_to have_been_made
299
+ expect(a_request(:put, //)).not_to have_been_made
300
+ end
301
+
302
+ it "comments on the MR to say secupdater has stopped updating it" do
303
+ publish
304
+
305
+ expect(comment_stub).to have_been_requested
306
+ end
307
+
308
+ context "when it has already said so" do
309
+ let(:existing_notes) do
310
+ [
311
+ { author: { id: 7 }, body: "Thanks, I've fixed the spec" },
312
+ { author: { id: 42 }, body: "<!-- secupdater:skipping -->\nThis branch has commits from Jo Bloggs…" }
313
+ ]
314
+ end
315
+
316
+ it "doesn't say it again" do
317
+ expect(publish.first).to eq(:skipped)
318
+
319
+ expect(a_request(:post, //)).not_to have_been_made
320
+ end
321
+ end
322
+
323
+ context "when only someone else has posted the marker (e.g. quoting it)" do
324
+ let(:existing_notes) { [{ author: { id: 7 }, body: "> <!-- secupdater:skipping -->" }] }
325
+
326
+ it "still says so" do
327
+ publish
328
+
329
+ expect(comment_stub).to have_been_requested
330
+ end
331
+ end
332
+ end
333
+
334
+ context "a branch without an MR, with a commit from someone else" do
335
+ let(:existing_merge_requests) { [] }
336
+ let(:branch_commits) { [{ author_name: "Jo Bloggs", author_email: "jo@example.com" }] }
337
+
338
+ it "skips it without commenting anywhere" do
339
+ expect(publish.first).to eq(:skipped)
340
+
341
+ expect(a_request(:any, %r{/notes})).not_to have_been_made
209
342
  end
210
343
  end
211
344
 
@@ -279,6 +412,32 @@ RSpec.describe Secupdater::Publisher::Gitlab do
279
412
  end
280
413
  end
281
414
 
415
+ context "an MR for a dependency whose advisories this run's MR for another dependency also fixes" do
416
+ let(:advisories) do
417
+ [
418
+ Secupdater::SecurityAdvisory.new("css_parser", "cve/2024-0001", [">= 1.1.0"]),
419
+ Secupdater::SecurityAdvisory.new("addressable", "ghsa/abcd-efgh", [">= 2.9.0"])
420
+ ]
421
+ end
422
+ let(:open_secupdater_merge_requests) do
423
+ [
424
+ { iid: 4, source_branch: "security/addressable-2.9.0" },
425
+ { iid: 9, source_branch: "security/css_parser-1.1.0" }
426
+ ]
427
+ end
428
+
429
+ before do
430
+ stub_request(:post, "#{project_api}/merge_requests").to_return(status: 201, body: { iid: 9 }.to_json)
431
+ publish
432
+ end
433
+
434
+ it "closes it, saying which MR superseded it" do
435
+ expect(gitlab.finish(["addressable", "css_parser"])).to eq(["closed !4 (security/addressable-2.9.0): Superseded by !9."])
436
+
437
+ expect(a_request(:put, //)).to have_been_made.once
438
+ end
439
+ end
440
+
282
441
  context "an MR for a dependency that no longer has advisories" do
283
442
  let(:open_secupdater_merge_requests) { [{ iid: 4, source_branch: "security/net-ssh-7.3.8" }] }
284
443
 
@@ -5,7 +5,8 @@ require "stringio"
5
5
  require "tmpdir"
6
6
 
7
7
  module UpdateSpec
8
- # outcomes: gem name => version try_update moves it to, :unchanged, :fail, :fail_unretryable or :abort, or a hash of those by level
8
+ # outcomes: gem name => version try_update moves it to, a hash of gem name => version when the update
9
+ # moves other gems too, :unchanged, :fail, :fail_unretryable or :abort, or a hash of those by level
9
10
  class FakeEcosystem
10
11
  LOCKFILE = "Gemfile.lock"
11
12
 
@@ -35,7 +36,7 @@ module UpdateSpec
35
36
  attempts << [name, level]
36
37
 
37
38
  outcome = outcomes.fetch(name)
38
- outcome = outcome.fetch(level) if outcome.is_a?(Hash)
39
+ outcome = outcome.fetch(level) if outcome.is_a?(Hash) && outcome.keys.all?(Symbol)
39
40
 
40
41
  case outcome
41
42
  when :fail
@@ -47,7 +48,8 @@ module UpdateSpec
47
48
  when :unchanged
48
49
  []
49
50
  else
50
- after = JSON.generate(JSON.parse(before).merge(name => outcome))
51
+ moves = outcome.is_a?(Hash) ? outcome : { name => outcome }
52
+ after = JSON.generate(JSON.parse(before).merge(moves))
51
53
  File.write(LOCKFILE, after)
52
54
  [Secupdater::FileChange.new(LOCKFILE, "update", after, before)]
53
55
  end
@@ -382,6 +384,127 @@ RSpec.describe Secupdater::Update do
382
384
  end
383
385
  end
384
386
 
387
+ context "one gem's update also fixes other vulnerable gems (e.g. rails-html-sanitizer needing newer loofah and nokogiri)" do
388
+ let(:lockfile) { { "rails-html-sanitizer" => "1.0", "loofah" => "1.0", "nokogiri" => "1.0" } }
389
+ let(:outcomes) do
390
+ {
391
+ "rails-html-sanitizer" => { "rails-html-sanitizer" => "2.0", "loofah" => "2.0", "nokogiri" => "2.0" },
392
+ "loofah" => { "loofah" => "2.0", "nokogiri" => "2.0" },
393
+ "nokogiri" => "2.0"
394
+ }
395
+ end
396
+ let(:advisories) do
397
+ [advisory("loofah", "ghsa/l"), advisory("nokogiri", "ghsa/n1"), advisory("nokogiri", "ghsa/n2"), advisory("rails-html-sanitizer", "ghsa/r")]
398
+ end
399
+
400
+ it "publishes the update that covers the most, once, with every gem's advisories" do
401
+ update.call(advisories)
402
+
403
+ expect(published.length).to eq(1)
404
+
405
+ _, bump, advs, changes = published.first
406
+ expect(bump.dependency.name).to eq("rails-html-sanitizer")
407
+ expect(advs.map(&:report_id)).to contain_exactly("ghsa/r", "ghsa/l", "ghsa/n1", "ghsa/n2")
408
+ expect(JSON.parse(changes.first.content)).to eq("rails-html-sanitizer" => "2.0", "loofah" => "2.0", "nokogiri" => "2.0")
409
+ end
410
+
411
+ it "returns :absorbed for the gems it covers" do
412
+ expect(update.call(advisories)).to eq(
413
+ "loofah" => :absorbed,
414
+ "nokogiri" => :absorbed,
415
+ "rails-html-sanitizer" => :published
416
+ )
417
+ end
418
+
419
+ it "logs which of its moves resolves each of the other gems' advisories" do
420
+ update.call(advisories)
421
+
422
+ expect(log.string).to match(%r{^INFO upgrading rails-html-sanitizer from 1\.0 to 2\.0 \(patch\) moves loofah from 1\.0 to 2\.0, which would resolve loofah ghsa/l})
423
+ end
424
+
425
+ it "tells the publisher to tidy up every gem, so their own MRs get closed" do
426
+ update.call(advisories)
427
+
428
+ expect(publisher).to have_received(:finish).with(["loofah", "nokogiri", "rails-html-sanitizer"])
429
+ end
430
+
431
+ it "leaves the lockfile as it found it" do
432
+ update.call(advisories)
433
+
434
+ expect(lockfile_on_disk).to eq(lockfile)
435
+ end
436
+
437
+ context "and the covering update was declined" do
438
+ before do
439
+ allow(publisher).to receive(:call) do |*args|
440
+ published << args
441
+ args[1].dependency.name == "rails-html-sanitizer" ? [:declined, "security/rails-html-sanitizer-2.0 was closed in !7"] : [:published, nil]
442
+ end
443
+ end
444
+
445
+ it "publishes the other gems without it, still folding them together where it can" do
446
+ expect(update.call(advisories)).to eq(
447
+ "loofah" => :published,
448
+ "nokogiri" => :absorbed,
449
+ "rails-html-sanitizer" => :declined
450
+ )
451
+
452
+ loofah = published.find { it[1].dependency.name == "loofah" }
453
+ expect(loofah[2].map(&:report_id)).to contain_exactly("ghsa/l", "ghsa/n1", "ghsa/n2")
454
+ end
455
+ end
456
+
457
+ context "with no publisher (dry run)" do
458
+ let(:publisher) { nil }
459
+
460
+ it "returns :resolved for the covering gem and :absorbed for the others" do
461
+ expect(update.call(advisories)).to eq(
462
+ "loofah" => :absorbed,
463
+ "nokogiri" => :absorbed,
464
+ "rails-html-sanitizer" => :resolved
465
+ )
466
+ end
467
+ end
468
+ end
469
+
470
+ context "a gem whose own update fails, but another gem's update fixes it" do
471
+ let(:outcomes) { { "rack" => { "rack" => "2.1", "nokogiri" => "2.0" }, "nokogiri" => :fail } }
472
+
473
+ it "returns :absorbed for it, without logging its failure as an error" do
474
+ expect(update.call([advisory("rack", "cve/1"), advisory("nokogiri", "cve/2")])).to eq("rack" => :published, "nokogiri" => :absorbed)
475
+ expect(log.string).not_to match(/^ERROR/)
476
+ end
477
+ end
478
+
479
+ context "a gem's update moves another vulnerable gem, but not far enough to fix it" do
480
+ let(:outcomes) { { "rack" => { "rack" => "2.1", "nokogiri" => "1.5" }, "nokogiri" => "2.0" } }
481
+
482
+ it "publishes each separately" do
483
+ expect(update.call([advisory("rack", "cve/1"), advisory("nokogiri", "cve/2")])).to eq("rack" => :published, "nokogiri" => :published)
484
+
485
+ expect(published.map { [it[1].dependency.name, it[2].map(&:report_id)] }).to contain_exactly(["rack", ["cve/1"]], ["nokogiri", ["cve/2"]])
486
+ end
487
+ end
488
+
489
+ context "a gem's update fixes another gem, but doesn't fix its own advisories" do
490
+ let(:outcomes) { { "rack" => { "rack" => "1.5", "nokogiri" => "2.0" }, "nokogiri" => "2.0" } }
491
+
492
+ it "doesn't absorb anything into it" do
493
+ expect(update.call([advisory("rack", "cve/1"), advisory("nokogiri", "cve/2")])).to eq("rack" => :unresolved, "nokogiri" => :published)
494
+ end
495
+ end
496
+
497
+ context "two gems whose updates each fix the other" do
498
+ let(:outcomes) do
499
+ { "rack" => { "rack" => "2.1", "nokogiri" => "2.0" }, "nokogiri" => { "rack" => "2.1", "nokogiri" => "2.0" } }
500
+ end
501
+
502
+ it "publishes one of them, picked by name, so every run agrees" do
503
+ expect(update.call([advisory("rack", "cve/1"), advisory("nokogiri", "cve/2")])).to eq("rack" => :absorbed, "nokogiri" => :published)
504
+ expect(published.length).to eq(1)
505
+ end
506
+ end
507
+
385
508
  context "a mix of outcomes" do
386
509
  let(:lockfile) { { "rack" => "1.0", "nokogiri" => "1.0", "puma" => "1.0", "rails" => "1.0" } }
387
510
  let(:outcomes) { { "rack" => "2.1", "nokogiri" => "1.5", "puma" => :unchanged, "rails" => :fail } }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: secupdater
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Anderson