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 +4 -4
- data/lib/secupdater/ecosystem/rubygems.rb +1 -1
- data/lib/secupdater/publisher/gitlab.rb +53 -8
- data/lib/secupdater/publisher.rb +4 -1
- data/lib/secupdater/update.rb +120 -40
- data/lib/secupdater/version.rb +1 -1
- data/spec/secupdater/ecosystem/rubygems_spec.rb +15 -0
- data/spec/secupdater/publisher/gitlab_spec.rb +162 -3
- data/spec/secupdater/update_spec.rb +126 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f19feed6401e9c35fd600135fcd76372d6f2b1146648996958119ac0063d5e3
|
|
4
|
+
data.tar.gz: 7fa86bc7f193bbe245dd672f9b1b362e3227917ebd92a78e28792cd810dbae4d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
data/lib/secupdater/publisher.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/secupdater/update.rb
CHANGED
|
@@ -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
|
-
|
|
20
|
-
[name,
|
|
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
|
-
|
|
29
|
-
logger.info("starting update for #{dep.name}, #{advisories.length} advisories")
|
|
37
|
+
private
|
|
30
38
|
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
return
|
|
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
|
-
|
|
43
|
-
logger.error message
|
|
44
|
-
else
|
|
45
|
-
logger.warn message
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
status
|
|
52
|
+
candidate
|
|
49
53
|
rescue Secupdater::UnretryableUpdateFailed => e
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
63
|
+
return Candidate.new(**attempt, status: :unchanged, message: "#{dep.name} didn't update (stuck at #{dep.version})") unless bump
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
rescue Secupdater::
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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)
|
data/lib/secupdater/version.rb
CHANGED
|
@@ -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(:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 } }
|