secupdater 0.1.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.
@@ -0,0 +1,109 @@
1
+ require "secupdater"
2
+ require "stringio"
3
+
4
+ RSpec.describe Secupdater::CLI::Commands::Run do
5
+ let(:out) { StringIO.new }
6
+ let(:err) { StringIO.new }
7
+ let(:runner) { instance_double(Secupdater::Runner, call: { "rack" => :published }) }
8
+ let(:env) { {} }
9
+
10
+ def run_cli(*arguments)
11
+ Dry::CLI.new(Secupdater::CLI::Commands).call(arguments: ["run", *arguments], out: out, err: err)
12
+ end
13
+
14
+ def expect_exit(status, &block)
15
+ expect(&block).to raise_error(SystemExit) { expect(it.status).to eq(status) }
16
+ end
17
+
18
+ before do
19
+ stub_const("ENV", env)
20
+ allow(Secupdater::Runner).to receive(:new).and_return(runner)
21
+ end
22
+
23
+ describe "#call" do
24
+ context "with --ecosystem rubygems and an advisories path" do
25
+ it "builds a Runner for rubygems at log level :warn and calls it with the advisories path" do
26
+ run_cli("--ecosystem=rubygems", "audit.json")
27
+
28
+ expect(Secupdater::Runner).to have_received(:new).with(ecosystem: "rubygems", log_level: :warn, publisher: nil)
29
+ expect(runner).to have_received(:call).with("audit.json")
30
+ end
31
+ end
32
+
33
+ context "with --verbose" do
34
+ it "builds the Runner with log level :info" do
35
+ run_cli("--ecosystem=rubygems", "--verbose", "audit.json")
36
+
37
+ expect(Secupdater::Runner).to have_received(:new).with(hash_including(log_level: :info))
38
+ end
39
+ end
40
+
41
+ context "with --debug" do
42
+ it "builds the Runner with log level :debug, even when --verbose is also given" do
43
+ run_cli("--ecosystem=rubygems", "--verbose", "--debug", "audit.json")
44
+
45
+ expect(Secupdater::Runner).to have_received(:new).with(hash_including(log_level: :debug))
46
+ end
47
+ end
48
+
49
+ context "with SECUPDATER_GITLAB_TOKEN set" do
50
+ let(:env) { { "SECUPDATER_GITLAB_TOKEN" => "token", "CI_PROJECT_ID" => "123", "CI_DEFAULT_BRANCH" => "main" } }
51
+
52
+ it "publishes to GitLab using the CI project and default branch" do
53
+ run_cli("--ecosystem=rubygems", "audit.json")
54
+
55
+ expect(Secupdater::Runner).to have_received(:new).with(hash_including(
56
+ publisher: an_instance_of(Secupdater::Publisher::Gitlab).and(
57
+ have_attributes(access_token: "token", project_id: "123", target_branch: "main")
58
+ )
59
+ ))
60
+ end
61
+ end
62
+
63
+ context "with SECUPDATER_GITLAB_TOKEN set but no CI_PROJECT_ID" do
64
+ let(:env) { { "SECUPDATER_GITLAB_TOKEN" => "token", "CI_DEFAULT_BRANCH" => "main" } }
65
+
66
+ it "prints which variable is missing and exits non-zero" do
67
+ expect_exit(1) { run_cli("--ecosystem=rubygems", "audit.json") }
68
+
69
+ expect(err.string).to eq("SECUPDATER_GITLAB_TOKEN is set but CI_PROJECT_ID isn't\n")
70
+ end
71
+ end
72
+
73
+ context "with an unknown --ecosystem" do
74
+ it "prints an error naming the bad option and exits non-zero" do
75
+ expect_exit(1) { run_cli("--ecosystem=npm", "audit.json") }
76
+
77
+ expect(err.string).to include("--ecosystem=npm")
78
+ expect(Secupdater::Runner).not_to have_received(:new)
79
+ end
80
+ end
81
+
82
+ context "without an advisories argument" do
83
+ it "prints usage and exits non-zero" do
84
+ expect_exit(1) { run_cli("--ecosystem=rubygems") }
85
+
86
+ expect(err.string).to include("ADVISORIES")
87
+ expect(Secupdater::Runner).not_to have_received(:new)
88
+ end
89
+ end
90
+
91
+ context "when the advisories file doesn't exist" do
92
+ before { allow(Secupdater::Runner).to receive(:new).and_call_original }
93
+
94
+ it "prints 'Cannot open <path>' and exits non-zero, rather than an Errno::ENOENT backtrace" do
95
+ expect_exit(1) { run_cli("--ecosystem=rubygems", "missing.json") }
96
+
97
+ expect(err.string).to eq("Cannot open missing.json\n")
98
+ end
99
+ end
100
+
101
+ context "when a dependency fails to update" do
102
+ let(:runner) { instance_double(Secupdater::Runner, call: { "rack" => :failed, "nokogiri" => :published }) }
103
+
104
+ it "exits non-zero" do
105
+ expect_exit(1) { run_cli("--ecosystem=rubygems", "audit.json") }
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,184 @@
1
+ require "secupdater"
2
+ require "stringio"
3
+ require "tmpdir"
4
+
5
+ RSpec.describe Secupdater::Ecosystem::Rubygems do
6
+ subject(:rubygems) { described_class.new }
7
+
8
+ def lockfile(css_parser_version)
9
+ <<~LOCKFILE
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ addressable (2.8.0)
14
+ css_parser (#{css_parser_version})
15
+ addressable
16
+
17
+ PLATFORMS
18
+ ruby
19
+
20
+ DEPENDENCIES
21
+ css_parser
22
+
23
+ BUNDLED WITH
24
+ 2.5.0
25
+ LOCKFILE
26
+ end
27
+
28
+ def spec(name, version) = Secupdater::DependencySpec.new(name, "ruby", Gem::Version.new(version))
29
+
30
+ around do |example|
31
+ Dir.mktmpdir do |dir|
32
+ Dir.chdir(dir) do
33
+ File.write("Gemfile.lock", lockfile("1.0.0"))
34
+ example.run
35
+ end
36
+ end
37
+ end
38
+
39
+ describe "#parse_advisories(io)" do
40
+ context "bundle-audit JSON with unpatched_gem and insecure_source results" do
41
+ let(:report) do
42
+ {
43
+ results: [
44
+ {
45
+ type: "unpatched_gem",
46
+ gem: { name: "css_parser", version: "1.0.0" },
47
+ advisory: { id: "CVE-2024-0001", cve: "2024-0001", osvdb: nil, ghsa: "abcd-efgh", patched_versions: [">= 1.1.0"] }
48
+ },
49
+ { type: "insecure_source", source: "http://rubygems.org/" }
50
+ ]
51
+ }
52
+ end
53
+
54
+ it "returns one advisory per unpatched gem with a joined cve/ghsa id and patched_versions" do
55
+ expect(rubygems.parse_advisories(StringIO.new(report.to_json))).to eq([
56
+ Secupdater::SecurityAdvisory.new("css_parser", "cve/2024-0001-ghsa/abcd-efgh", [">= 1.1.0"])
57
+ ])
58
+ end
59
+ end
60
+
61
+ context "an advisory with none of cve, osvdb or ghsa" do
62
+ let(:report) do
63
+ {
64
+ results: [
65
+ {
66
+ type: "unpatched_gem",
67
+ gem: { name: "css_parser", version: "1.0.0" },
68
+ advisory: { id: "RUBYSEC-2024-1", cve: nil, osvdb: nil, ghsa: nil, patched_versions: [">= 1.1.0"] }
69
+ }
70
+ ]
71
+ }
72
+ end
73
+
74
+ it "falls back to the advisory id" do
75
+ expect(rubygems.parse_advisories(StringIO.new(report.to_json)).map(&:report_id)).to eq(["RUBYSEC-2024-1"])
76
+ end
77
+ end
78
+ end
79
+
80
+ describe "#snapshot" do
81
+ context "fixture Gemfile.lock in root" do
82
+ it "maps gem name to DependencySpec with version" do
83
+ expect(rubygems.snapshot).to eq(
84
+ "addressable" => spec("addressable", "2.8.0"),
85
+ "css_parser" => spec("css_parser", "1.0.0")
86
+ )
87
+ end
88
+ end
89
+ end
90
+
91
+ describe "#diff_snapshot" do
92
+ context "one gem bumped, one added, one removed" do
93
+ it "returns a diff for each with from/to" do
94
+ before = { "css_parser" => spec("css_parser", "1.0.0"), "public_suffix" => spec("public_suffix", "4.0.0") }
95
+ after = { "css_parser" => spec("css_parser", "1.1.0"), "addressable" => spec("addressable", "2.8.0") }
96
+
97
+ expect(rubygems.diff_snapshot(before, after)).to contain_exactly(
98
+ Secupdater::DependencySpecDiff.new(spec("css_parser", "1.0.0"), Gem::Version.new("1.0.0"), Gem::Version.new("1.1.0")),
99
+ Secupdater::DependencySpecDiff.new(spec("public_suffix", "4.0.0"), Gem::Version.new("4.0.0"), nil),
100
+ Secupdater::DependencySpecDiff.new(spec("addressable", "2.8.0"), nil, Gem::Version.new("2.8.0"))
101
+ )
102
+ end
103
+ end
104
+
105
+ context "unchanged gems" do
106
+ it "leaves them out of the diff" do
107
+ snapshot = { "css_parser" => spec("css_parser", "1.0.0") }
108
+
109
+ expect(rubygems.diff_snapshot(snapshot, snapshot.dup)).to be_empty
110
+ end
111
+ end
112
+ end
113
+
114
+ describe "#satisfies_requirement?" do
115
+ it "matches ~>, >= and a version that's too low" do
116
+ expect(rubygems.satisfies_requirement?("1.1.5", "~> 1.1.4")).to be(true)
117
+ expect(rubygems.satisfies_requirement?("1.2.0", "~> 1.1.4")).to be(false)
118
+ expect(rubygems.satisfies_requirement?("2.0.0", ">= 1.1.0")).to be(true)
119
+ expect(rubygems.satisfies_requirement?("1.0.0", ">= 1.1.0")).to be(false)
120
+ end
121
+
122
+ it "matches comma-separated requirements from bundle audit" do
123
+ expect(rubygems.satisfies_requirement?("7.3.5", "~> 7.3, >= 7.3.5")).to be(true)
124
+ expect(rubygems.satisfies_requirement?("7.3.4", "~> 7.3, >= 7.3.5")).to be(false)
125
+ expect(rubygems.satisfies_requirement?("8.0.0", "~> 7.3, >= 7.3.5")).to be(false)
126
+ end
127
+ end
128
+
129
+ describe "#try_update" do
130
+ let(:status) { instance_double(Process::Status, success?: true, exitstatus: 0) }
131
+
132
+ context "command succeeds" do
133
+ before do
134
+ allow(Open3).to receive(:capture2e) do
135
+ File.write("Gemfile.lock", lockfile("1.1.0"))
136
+ ["Bundle updated!", status]
137
+ end
138
+ end
139
+
140
+ it "runs a strict, conservative bundle update at the given level" do
141
+ rubygems.try_update("css_parser", level: :minor)
142
+
143
+ expect(Open3).to have_received(:capture2e).with("bundle", "update", "css_parser", "--conservative", "--minor", "--strict")
144
+ end
145
+
146
+ it "returns the lockfile change with its new and original content" do
147
+ expect(rubygems.try_update("css_parser", level: :patch)).to eq([
148
+ Secupdater::FileChange.new("Gemfile.lock", "update", lockfile("1.1.0"), lockfile("1.0.0"))
149
+ ])
150
+ end
151
+ end
152
+
153
+ context "command succeeds without changing anything" do
154
+ before { allow(Open3).to receive(:capture2e).and_return(["Bundle updated!", status]) }
155
+
156
+ it "returns no file changes" do
157
+ expect(rubygems.try_update("css_parser", level: :patch)).to eq([])
158
+ end
159
+ end
160
+
161
+ context "gem name containing shell metacharacters" do
162
+ before { allow(Open3).to receive(:capture2e).and_return(["", status]) }
163
+
164
+ it "passes it as a single argument without going through a shell" do
165
+ rubygems.try_update("css_parser; touch pwned", level: :patch)
166
+
167
+ expect(Open3).to have_received(:capture2e).with("bundle", "update", "css_parser; touch pwned", "--conservative", "--patch", "--strict")
168
+ end
169
+ end
170
+
171
+ context "command exits 1" do
172
+ let(:status) { instance_double(Process::Status, success?: false, exitstatus: 1) }
173
+
174
+ before { allow(Open3).to receive(:capture2e).and_return(["Could not find gem 'css_parser'", status]) }
175
+
176
+ it "raises UpdateCommandFailed with the integer exit code and combined output" do
177
+ expect { rubygems.try_update("css_parser", level: :patch) }.to raise_error(Secupdater::UpdateCommandFailed) do
178
+ expect(it.exit_code).to eq(1)
179
+ expect(it.output).to eq("Could not find gem 'css_parser'")
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,18 @@
1
+ require "secupdater"
2
+
3
+ RSpec.describe Secupdater::Ecosystem do
4
+ describe ".resolve" do
5
+ context "'rubygems'" do
6
+ it "returns a Secupdater::Ecosystem::Rubygems instance" do
7
+ expect(described_class.resolve("rubygems")).to be_a(Secupdater::Ecosystem::Rubygems)
8
+ expect(described_class.resolve("RubyGems")).to be_a(Secupdater::Ecosystem::Rubygems)
9
+ end
10
+ end
11
+
12
+ context "unknown ecosystem" do
13
+ it "raises Secupdater::Error naming the ecosystem" do
14
+ expect { described_class.resolve("npm") }.to raise_error(Secupdater::Error, "Unknown ecosystem: npm")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,106 @@
1
+ require "secupdater"
2
+ require "secupdater/publisher"
3
+ require "secupdater/publisher/gitlab"
4
+
5
+ RSpec.describe Secupdater::Publisher::Gitlab do
6
+ subject(:gitlab) { described_class.new("access_token", "123", "main") }
7
+
8
+ let(:ecosystem) { Secupdater::Ecosystem::Rubygems.new }
9
+ let(:dep) { Secupdater::DependencySpec.new("css_parser", "ruby", Gem::Version.new("1.0.0")) }
10
+ let(:advisories) { [Secupdater::SecurityAdvisory.new("css_parser", "cve/2024-0001", [">= 1.1.0"])] }
11
+ let(:bump) { Secupdater::DependencySpecDiff.new(dep, "1.0.0", "1.1.0") }
12
+ let(:changes) { [Secupdater::FileChange.new("Gemfile.lock", "update", "abc", "xyz")] }
13
+
14
+ let(:headers) do
15
+ {
16
+ "PRIVATE-TOKEN" => "access_token",
17
+ content_type: "application/json"
18
+ }
19
+ end
20
+
21
+ let(:existing_merge_requests) { [] }
22
+
23
+ let!(:find_mr_stub) do
24
+ stub_request(:get, "https://gitlab.com/api/v4/projects/123/merge_requests")
25
+ .with(query: { source_branch: "security/css_parser-1.1.0", state: "opened" }, headers: headers)
26
+ .to_return(status: 200, body: existing_merge_requests.to_json)
27
+ end
28
+
29
+ let!(:create_commit_stub) do
30
+ stub_request(:post, "https://gitlab.com/api/v4/projects/123/repository/commits")
31
+ .with(
32
+ body: {
33
+ branch: "security/css_parser-1.1.0",
34
+ start_branch: "main",
35
+ commit_message: "Upgrade RubyGems dependency css_parser to version 1.1.0",
36
+ actions: [{ action: "update", file_path: "Gemfile.lock", content: "abc" }]
37
+ },
38
+ headers: headers
39
+ )
40
+ .to_return(status: 201, body: "{}")
41
+ end
42
+
43
+ let!(:create_mr_stub) do
44
+ stub_request(:post, "https://gitlab.com/api/v4/projects/123/merge_requests")
45
+ .with(body: {
46
+ source_branch: "security/css_parser-1.1.0",
47
+ target_branch: "main",
48
+ remove_source_branch: true,
49
+ labels: [],
50
+ title: "Upgrade RubyGems dependency css_parser to version 1.1.0",
51
+ description: "Upgrade RubyGems dependency css_parser to version 1.1.0 to address the following security advisories:\n\n* cve/2024-0001\n",
52
+ }, headers: headers)
53
+ .to_return(status: 201, body: "{}")
54
+ end
55
+
56
+ describe "#call(ecosystem, bump, advisories, changes)" do
57
+ it "creates a commit and a merge request" do
58
+ gitlab.call(ecosystem, bump, advisories, changes)
59
+
60
+ expect(create_commit_stub).to have_been_requested
61
+ expect(create_mr_stub).to have_been_requested
62
+ end
63
+
64
+ context "deleting a file" do
65
+ let(:changes) { [Secupdater::FileChange.new("Gemfile.lock", "delete", nil, "xyz")] }
66
+
67
+ let!(:create_commit_stub) do
68
+ stub_request(:post, "https://gitlab.com/api/v4/projects/123/repository/commits")
69
+ .with(body: hash_including(actions: [{ action: "delete", file_path: "Gemfile.lock" }]))
70
+ .to_return(status: 201, body: "{}")
71
+ end
72
+
73
+ it "doesn't send content for the action" do
74
+ gitlab.call(ecosystem, bump, advisories, changes)
75
+
76
+ expect(create_commit_stub).to have_been_requested
77
+ end
78
+ end
79
+
80
+ context "API returns 4xx" do
81
+ let!(:create_commit_stub) do
82
+ stub_request(:post, "https://gitlab.com/api/v4/projects/123/repository/commits")
83
+ .to_return(status: 400, body: '{"message":"A branch called security/css_parser-1.1.0 already exists"}')
84
+ end
85
+
86
+ it "raises Secupdater::Error including the response body" do
87
+ expect { gitlab.call(ecosystem, bump, advisories, changes) }
88
+ .to raise_error(Secupdater::Error, /400.*already exists/)
89
+
90
+ expect(create_mr_stub).not_to have_been_requested
91
+ end
92
+ end
93
+
94
+ context "an MR for the branch already exists" do
95
+ let(:existing_merge_requests) { [{ iid: 1 }] }
96
+
97
+ it "doesn't create another one" do
98
+ gitlab.call(ecosystem, bump, advisories, changes)
99
+
100
+ expect(find_mr_stub).to have_been_requested
101
+ expect(create_commit_stub).not_to have_been_requested
102
+ expect(create_mr_stub).not_to have_been_requested
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,10 @@
1
+ require "secupdater"
2
+ require "secupdater/publisher"
3
+
4
+ RSpec.describe Secupdater::Publisher do
5
+ describe ".new" do
6
+ it "can't be instantiated directly" do
7
+ expect { described_class.new }.to raise_error(NotImplementedError)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,56 @@
1
+ require "secupdater"
2
+ require "tmpdir"
3
+
4
+ RSpec.describe Secupdater::Runner do
5
+ describe "#initialize" do
6
+ context "with ecosystem 'rubygems'" do
7
+ it "resolves the Rubygems ecosystem" do
8
+ expect(Secupdater::Ecosystem).to receive(:resolve).with("rubygems").and_call_original
9
+
10
+ described_class.new(ecosystem: "rubygems")
11
+ end
12
+ end
13
+
14
+ context "with an unknown ecosystem" do
15
+ it "raises Secupdater::Error naming the ecosystem" do
16
+ expect { described_class.new(ecosystem: "npm") }.to raise_error(Secupdater::Error, /npm/)
17
+ end
18
+ end
19
+ end
20
+
21
+ describe "#call(advisories_path)" do
22
+ subject(:runner) { described_class.new(ecosystem: "rubygems", publisher: publisher) }
23
+
24
+ let(:ecosystem) { instance_double(Secupdater::Ecosystem::Rubygems) }
25
+ let(:publisher) { instance_double(Secupdater::Publisher) }
26
+ let(:update) { instance_double(Secupdater::Update, call: { "rack" => :published }) }
27
+ let(:advisories) { [Secupdater::SecurityAdvisory.new("rack", "cve/1", [">= 2.0"])] }
28
+
29
+ around do |example|
30
+ Dir.mktmpdir do |dir|
31
+ Dir.chdir(dir) { example.run }
32
+ end
33
+ end
34
+
35
+ before do
36
+ File.write("audit.json", "{}")
37
+ allow(Secupdater::Ecosystem).to receive(:resolve).and_return(ecosystem)
38
+ allow(ecosystem).to receive(:parse_advisories) { |io| advisories if io.read == "{}" }
39
+ allow(Secupdater::Update).to receive(:new).and_return(update)
40
+ end
41
+
42
+ it "parses the advisories file with the ecosystem and passes them to Update" do
43
+ expect(runner.call("audit.json")).to eq("rack" => :published)
44
+
45
+ expect(Secupdater::Update).to have_received(:new)
46
+ .with(an_instance_of(Dry::Logger::Dispatcher), ecosystem, publisher: publisher)
47
+ expect(update).to have_received(:call).with(advisories)
48
+ end
49
+
50
+ context "when the advisories file doesn't exist" do
51
+ it "raises Secupdater::Error naming the path" do
52
+ expect { runner.call("missing.json") }.to raise_error(Secupdater::Error, "Cannot open missing.json")
53
+ end
54
+ end
55
+ end
56
+ end