terracop 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.
- checksums.yaml +7 -0
- data/.github/workflows/ruby.yml +20 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +14 -0
- data/.travis.yml +7 -0
- data/CHANGELOG.md +14 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +66 -0
- data/LICENSE.md +21 -0
- data/README.md +49 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/bin/terracop +52 -0
- data/default_config.yml +1 -0
- data/lib/terracop/cop/aws/describe_security_group_rules.rb +35 -0
- data/lib/terracop/cop/aws/ensure_tags.rb +51 -0
- data/lib/terracop/cop/aws/iam_role_policy.rb +47 -0
- data/lib/terracop/cop/aws/open_egress.rb +42 -0
- data/lib/terracop/cop/aws/open_ingress.rb +44 -0
- data/lib/terracop/cop/aws/open_ssh.rb +39 -0
- data/lib/terracop/cop/aws/security_group_rule_cop.rb +45 -0
- data/lib/terracop/cop/aws/unrestricted_egress_ports.rb +37 -0
- data/lib/terracop/cop/aws/unrestricted_ingress_ports.rb +38 -0
- data/lib/terracop/cop/aws/wide_egress.rb +53 -0
- data/lib/terracop/cop/aws/wide_ingress.rb +53 -0
- data/lib/terracop/cop/base.rb +105 -0
- data/lib/terracop/cop/style/dash_in_resource_name.rb +35 -0
- data/lib/terracop/cop/style/resource_type_in_name.rb +53 -0
- data/lib/terracop/cop/style/snake_case.rb +35 -0
- data/lib/terracop/formatters/default.rb +25 -0
- data/lib/terracop/formatters/html.rb +16 -0
- data/lib/terracop/formatters/json.rb +53 -0
- data/lib/terracop/formatters/report.html.erb +289 -0
- data/lib/terracop/plan_loader.rb +39 -0
- data/lib/terracop/runner.rb +50 -0
- data/lib/terracop/state_loader.rb +39 -0
- data/lib/terracop/version.rb +5 -0
- data/lib/terracop.rb +49 -0
- data/terracop.gemspec +45 -0
- metadata +200 -0
@@ -0,0 +1,289 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta charset='UTF-8' />
|
5
|
+
<title>Terracop Inspection Report</title>
|
6
|
+
<style>
|
7
|
+
* {
|
8
|
+
-webkit-box-sizing: border-box;
|
9
|
+
-moz-box-sizing: border-box;
|
10
|
+
box-sizing: border-box;
|
11
|
+
}
|
12
|
+
|
13
|
+
body, html {
|
14
|
+
font-size: 62.5%;
|
15
|
+
}
|
16
|
+
body {
|
17
|
+
background-color: #ecedf0;
|
18
|
+
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
|
19
|
+
margin: 0;
|
20
|
+
}
|
21
|
+
code {
|
22
|
+
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
23
|
+
font-size: 85%;
|
24
|
+
}
|
25
|
+
#header {
|
26
|
+
background: #f9f9f9;
|
27
|
+
color: #333;
|
28
|
+
border-bottom: 3px solid #ccc;
|
29
|
+
height: 50px;
|
30
|
+
padding: 0;
|
31
|
+
}
|
32
|
+
#header .logo {
|
33
|
+
float: left;
|
34
|
+
margin: 5px 12px 7px 20px;
|
35
|
+
width: 38px;
|
36
|
+
height: 38px;
|
37
|
+
}
|
38
|
+
#header .title {
|
39
|
+
display: inline-block;
|
40
|
+
float: left;
|
41
|
+
height: 50px;
|
42
|
+
font-size: 2.4rem;
|
43
|
+
letter-spacing: normal;
|
44
|
+
line-height: 50px;
|
45
|
+
margin: 0;
|
46
|
+
}
|
47
|
+
|
48
|
+
.information, #offenses {
|
49
|
+
width: 100%;
|
50
|
+
padding: 20px;
|
51
|
+
color: #333;
|
52
|
+
}
|
53
|
+
#offenses {
|
54
|
+
padding: 0 20px;
|
55
|
+
}
|
56
|
+
|
57
|
+
.information .infobox {
|
58
|
+
border-left: 3px solid;
|
59
|
+
border-radius: 4px;
|
60
|
+
background-color: #fff;
|
61
|
+
-webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
62
|
+
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
63
|
+
padding: 15px;
|
64
|
+
border-color: #0088cc;
|
65
|
+
font-size: 1.4rem;
|
66
|
+
}
|
67
|
+
.information .infobox .info-title {
|
68
|
+
font-size: 1.8rem;
|
69
|
+
line-height: 2.2rem;
|
70
|
+
margin: 0 0 0.5em;
|
71
|
+
}
|
72
|
+
.information .offenses-list li {
|
73
|
+
line-height: 1.8rem
|
74
|
+
}
|
75
|
+
.information .offenses-list {
|
76
|
+
padding-left: 20px;
|
77
|
+
margin-bottom: 0;
|
78
|
+
}
|
79
|
+
|
80
|
+
#offenses .offense-box {
|
81
|
+
border-radius: 4px;
|
82
|
+
margin-bottom: 20px;
|
83
|
+
background-color: #fff;
|
84
|
+
-webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
85
|
+
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
86
|
+
}
|
87
|
+
.fixed .box-title {
|
88
|
+
position: fixed;
|
89
|
+
top: 0;
|
90
|
+
z-index: 10;
|
91
|
+
width: 100%;
|
92
|
+
}
|
93
|
+
.box-title-placeholder {
|
94
|
+
display: none;
|
95
|
+
}
|
96
|
+
.fixed .box-title-placeholder {
|
97
|
+
display: block;
|
98
|
+
}
|
99
|
+
#offenses .offense-box .box-title h3, #offenses .offense-box .box-title-placeholder h3 {
|
100
|
+
color: #33353f;
|
101
|
+
background-color: #f6f6f6;
|
102
|
+
font-size: 2rem;
|
103
|
+
line-height: 2rem;
|
104
|
+
display: block;
|
105
|
+
padding: 15px;
|
106
|
+
border-radius: 5px;
|
107
|
+
margin: 0;
|
108
|
+
}
|
109
|
+
#offenses .offense-box .offense-reports {
|
110
|
+
padding: 0 15px;
|
111
|
+
}
|
112
|
+
#offenses .offense-box .offense-reports .report {
|
113
|
+
border-bottom: 1px dotted #ddd;
|
114
|
+
padding: 15px 0px;
|
115
|
+
position: relative;
|
116
|
+
font-size: 1.3rem;
|
117
|
+
}
|
118
|
+
#offenses .offense-box .offense-reports .report:last-child {
|
119
|
+
border-bottom: none;
|
120
|
+
}
|
121
|
+
#offenses .offense-box .offense-reports .report pre code {
|
122
|
+
display: block;
|
123
|
+
background: #000;
|
124
|
+
color: #fff;
|
125
|
+
padding: 10px 15px;
|
126
|
+
border-radius: 5px;
|
127
|
+
line-height: 1.6rem;
|
128
|
+
}
|
129
|
+
#offenses .offense-box .offense-reports .report .location {
|
130
|
+
font-weight: bold;
|
131
|
+
}
|
132
|
+
#offenses .offense-box .offense-reports .report .message code {
|
133
|
+
padding: 0.3em;
|
134
|
+
background-color: rgba(0,0,0,0.07);
|
135
|
+
border-radius: 3px;
|
136
|
+
}
|
137
|
+
.severity {
|
138
|
+
text-transform: capitalize;
|
139
|
+
font-weight: bold;
|
140
|
+
}
|
141
|
+
.highlight {
|
142
|
+
padding: 2px;
|
143
|
+
border-radius: 2px;
|
144
|
+
font-weight: bold;
|
145
|
+
}
|
146
|
+
|
147
|
+
.severity.refactor {
|
148
|
+
color: rgba(237, 156, 40, 1.0);
|
149
|
+
}
|
150
|
+
.highlight.refactor {
|
151
|
+
background-color: rgba(237, 156, 40, 0.6);
|
152
|
+
border: 1px solid rgba(237, 156, 40, 0.4);
|
153
|
+
}
|
154
|
+
|
155
|
+
.severity.convention {
|
156
|
+
color: rgba(237, 156, 40, 1.0);
|
157
|
+
}
|
158
|
+
.severity.security {
|
159
|
+
color: red;
|
160
|
+
}
|
161
|
+
.highlight.convention {
|
162
|
+
background-color: rgba(237, 156, 40, 0.6);
|
163
|
+
border: 1px solid rgba(237, 156, 40, 0.4);
|
164
|
+
}
|
165
|
+
|
166
|
+
.severity.warning {
|
167
|
+
color: rgba(150, 40, 239, 1.0);
|
168
|
+
}
|
169
|
+
.highlight.warning {
|
170
|
+
background-color: rgba(150, 40, 239, 0.6);
|
171
|
+
border: 1px solid rgba(150, 40, 239, 0.4);
|
172
|
+
}
|
173
|
+
|
174
|
+
.severity.error {
|
175
|
+
color: rgba(210, 50, 45, 1.0);
|
176
|
+
}
|
177
|
+
.highlight.error {
|
178
|
+
background-color: rgba(210, 50, 45, 0.6);
|
179
|
+
border: 1px solid rgba(210, 50, 45, 0.4);
|
180
|
+
}
|
181
|
+
|
182
|
+
.severity.fatal {
|
183
|
+
color: rgba(210, 50, 45, 1.0);
|
184
|
+
}
|
185
|
+
.highlight.fatal {
|
186
|
+
background-color: rgba(210, 50, 45, 0.6);
|
187
|
+
border: 1px solid rgba(210, 50, 45, 0.4);
|
188
|
+
}
|
189
|
+
|
190
|
+
footer {
|
191
|
+
margin-bottom: 20px;
|
192
|
+
margin-right: 20px;
|
193
|
+
font-size: 1.3rem;
|
194
|
+
color: #777;
|
195
|
+
text-align: right;
|
196
|
+
}
|
197
|
+
.extra-code {
|
198
|
+
color: #ED9C28
|
199
|
+
}
|
200
|
+
</style>
|
201
|
+
|
202
|
+
<script>
|
203
|
+
(function() {
|
204
|
+
// floating headers. requires classList support.
|
205
|
+
if (!('classList' in document.createElement("_"))) return;
|
206
|
+
|
207
|
+
var loaded = false,
|
208
|
+
boxes,
|
209
|
+
boxPositions;
|
210
|
+
|
211
|
+
window.onload = function() {
|
212
|
+
var scrollY = window.scrollY;
|
213
|
+
boxes = document.querySelectorAll('.offense-box');
|
214
|
+
boxPositions = [];
|
215
|
+
for (var i = 0; i < boxes.length; i++)
|
216
|
+
// need to add scrollY because the page might be somewhere other than the top when loaded.
|
217
|
+
boxPositions[i] = boxes[i].getBoundingClientRect().top + scrollY;
|
218
|
+
loaded = true;
|
219
|
+
};
|
220
|
+
|
221
|
+
window.onscroll = function() {
|
222
|
+
if (!loaded) return;
|
223
|
+
var i,
|
224
|
+
idx,
|
225
|
+
scrollY = window.scrollY;
|
226
|
+
for (i = 0; i < boxPositions.length; i++) {
|
227
|
+
if (scrollY <= boxPositions[i] - 1) {
|
228
|
+
idx = i;
|
229
|
+
break;
|
230
|
+
}
|
231
|
+
}
|
232
|
+
if (typeof idx == 'undefined') idx = boxes.length;
|
233
|
+
if (idx > 0)
|
234
|
+
boxes[idx - 1].classList.add('fixed');
|
235
|
+
for (i = 0; i < boxes.length; i++) {
|
236
|
+
if (i < idx) continue;
|
237
|
+
boxes[i].classList.remove('fixed');
|
238
|
+
}
|
239
|
+
};
|
240
|
+
})();
|
241
|
+
</script>
|
242
|
+
</head>
|
243
|
+
<body>
|
244
|
+
<div id="header">
|
245
|
+
<h1 class="title">TerraCop Inspection Report</h1>
|
246
|
+
</div>
|
247
|
+
<div class="information">
|
248
|
+
<div class="infobox">
|
249
|
+
<div class="total">
|
250
|
+
<%= resources.count %> resources inspected,
|
251
|
+
<%= resources.values.map(&:count).sum %> offenses detected:
|
252
|
+
</div>
|
253
|
+
<ul class="offenses-list">
|
254
|
+
<% resources.each do |resource, offenses| %>
|
255
|
+
<li>
|
256
|
+
<a href="#<%= resource %>">
|
257
|
+
<%= resource %> - <%= offenses.count %> offenses
|
258
|
+
</a>
|
259
|
+
</li>
|
260
|
+
<% end %>
|
261
|
+
</ul>
|
262
|
+
</div>
|
263
|
+
</div>
|
264
|
+
|
265
|
+
<div id="offenses">
|
266
|
+
<% resources.each do |resource, offenses| %>
|
267
|
+
<div class="offense-box" id="<%= resource %>">
|
268
|
+
<div class="box-title-placeholder"><h3> </h3></div>
|
269
|
+
<div class="box-title"><h3><%= resource %> - <%= offenses.count %> offenses</h3></div>
|
270
|
+
<div class="offense-reports">
|
271
|
+
<% offenses.each do |offense| %>
|
272
|
+
<div class="report">
|
273
|
+
<div class="meta">
|
274
|
+
<span class="severity <%= offense[:severity] %>"><%= offense[:severity] %>:</span>
|
275
|
+
<span class="message"><b><%= offense[:cop_name] %></b>: <%= offense[:message] %></span>
|
276
|
+
</div>
|
277
|
+
</div>
|
278
|
+
<% end %>
|
279
|
+
</div>
|
280
|
+
</div>
|
281
|
+
<% end %>
|
282
|
+
</div>
|
283
|
+
|
284
|
+
<footer>
|
285
|
+
Generated by <a href="https://github.com/aomega08/terracop">TerraCop</a>
|
286
|
+
<span class="version"><%= Terracop::VERSION %></span>
|
287
|
+
</footer>
|
288
|
+
</body>
|
289
|
+
</html>
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Terracop
|
4
|
+
# Loads a Terraform plan file and transforms it into a Terracop-friendly list
|
5
|
+
# of instances.
|
6
|
+
class PlanLoader
|
7
|
+
class << self
|
8
|
+
def load(file)
|
9
|
+
plan = decode(file)
|
10
|
+
|
11
|
+
changed_resources = plan['resource_changes'].reject! do |resource|
|
12
|
+
resource['change']['actions'] == ['no-op']
|
13
|
+
end
|
14
|
+
|
15
|
+
restruct_resources(changed_resources)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def decode(file)
|
21
|
+
JSON.parse(`terraform show -json #{file}`)
|
22
|
+
rescue JSON::ParserError
|
23
|
+
puts 'Terraform failed to decode the plan file.'
|
24
|
+
exit
|
25
|
+
end
|
26
|
+
|
27
|
+
def restruct_resources(resources)
|
28
|
+
resources.map do |resource|
|
29
|
+
{
|
30
|
+
type: resource['type'],
|
31
|
+
name: resource['name'],
|
32
|
+
index: resource['index'],
|
33
|
+
attributes: resource['change']['after']
|
34
|
+
}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Terracop
|
4
|
+
# Executes Terracop on a given state file.
|
5
|
+
class Runner
|
6
|
+
attr_accessor :state
|
7
|
+
|
8
|
+
def initialize(type, file, formatter)
|
9
|
+
@formatter = Formatters.const_get(formatter.to_s.capitalize).new
|
10
|
+
|
11
|
+
if file
|
12
|
+
load_state_from_file(type, file)
|
13
|
+
else
|
14
|
+
load_state_from_terraform
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def run
|
19
|
+
offenses = @state.map do |instance|
|
20
|
+
Terracop::Cop.run_for(
|
21
|
+
instance[:type], instance[:name],
|
22
|
+
instance[:index], instance[:attributes]
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
by_res = offenses.flatten.group_by { |o| "#{o[:type]}.#{o[:name]}" }
|
27
|
+
print @formatter.generate(by_res)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def load_state_from_file(type, file)
|
33
|
+
@state = if type == :plan
|
34
|
+
PlanLoader.load(file)
|
35
|
+
else
|
36
|
+
StateLoader.load(file)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# :nocov:
|
41
|
+
def load_state_from_terraform
|
42
|
+
@state = StateLoader.load_from_text(`terraform state pull`)
|
43
|
+
rescue JSON::ParserError
|
44
|
+
puts 'Run terracop somewhere with a state file or pass it directly ' \
|
45
|
+
'with --state FILE'
|
46
|
+
exit
|
47
|
+
end
|
48
|
+
# :nocov:
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Terracop
|
4
|
+
# Loads a Terraform state file and transforms it into a Terracop-friendly list
|
5
|
+
# of instances.
|
6
|
+
class StateLoader
|
7
|
+
class << self
|
8
|
+
def load(file)
|
9
|
+
state = File.read(file)
|
10
|
+
load_from_text(state)
|
11
|
+
end
|
12
|
+
|
13
|
+
def load_from_text(text)
|
14
|
+
state = JSON.parse(text)
|
15
|
+
|
16
|
+
managed_resources = state['resources'].select do |resource|
|
17
|
+
resource['mode'] == 'managed'
|
18
|
+
end
|
19
|
+
|
20
|
+
flatten_instances(managed_resources)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def flatten_instances(resources)
|
26
|
+
resources.map do |resource|
|
27
|
+
resource['instances'].map do |instance|
|
28
|
+
{
|
29
|
+
type: resource['type'],
|
30
|
+
name: resource['name'],
|
31
|
+
index: instance['index_key'],
|
32
|
+
attributes: instance['attributes']
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end.flatten
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/terracop.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'yaml'
|
5
|
+
|
6
|
+
require 'terracop/cop/aws/describe_security_group_rules'
|
7
|
+
require 'terracop/cop/aws/ensure_tags'
|
8
|
+
require 'terracop/cop/aws/iam_role_policy'
|
9
|
+
require 'terracop/cop/aws/open_egress'
|
10
|
+
require 'terracop/cop/aws/open_ingress'
|
11
|
+
require 'terracop/cop/aws/open_ssh'
|
12
|
+
require 'terracop/cop/aws/unrestricted_egress_ports'
|
13
|
+
require 'terracop/cop/aws/unrestricted_ingress_ports'
|
14
|
+
require 'terracop/cop/aws/wide_egress'
|
15
|
+
require 'terracop/cop/aws/wide_ingress'
|
16
|
+
|
17
|
+
require 'terracop/cop/style/dash_in_resource_name'
|
18
|
+
require 'terracop/cop/style/resource_type_in_name'
|
19
|
+
require 'terracop/cop/style/snake_case'
|
20
|
+
|
21
|
+
require 'terracop/formatters/default'
|
22
|
+
require 'terracop/formatters/html'
|
23
|
+
require 'terracop/formatters/json'
|
24
|
+
|
25
|
+
require 'terracop/plan_loader'
|
26
|
+
require 'terracop/runner'
|
27
|
+
require 'terracop/state_loader'
|
28
|
+
require 'terracop/version'
|
29
|
+
|
30
|
+
# Wrapper module for the gem.
|
31
|
+
module Terracop
|
32
|
+
class Error < StandardError; end
|
33
|
+
|
34
|
+
class << self
|
35
|
+
def config
|
36
|
+
@config ||= begin
|
37
|
+
defaults_path = File.join(__dir__, '../default_config.yml')
|
38
|
+
overrides_path = '.terracop.yml'
|
39
|
+
|
40
|
+
config = YAML.safe_load(File.read(defaults_path)) || {}
|
41
|
+
if File.exist?(overrides_path)
|
42
|
+
config.merge!(YAML.safe_load(File.read(overrides_path)) || {})
|
43
|
+
end
|
44
|
+
|
45
|
+
config
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
data/terracop.gemspec
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'terracop/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = 'terracop'
|
9
|
+
spec.version = Terracop::VERSION
|
10
|
+
spec.authors = ['Francesco Boffa']
|
11
|
+
spec.email = ['fra.boffa@gmail.com']
|
12
|
+
spec.license = 'MIT'
|
13
|
+
|
14
|
+
spec.summary = 'Automatic Terraform state/plan checking tool'
|
15
|
+
spec.description = <<-DESCRIPTION
|
16
|
+
Automatic Terraform state/plan checking tool.
|
17
|
+
Aims to enforce best practices for Terraform and "the cloud".
|
18
|
+
DESCRIPTION
|
19
|
+
|
20
|
+
spec.homepage = 'https://github.com/aomega08/terracop'
|
21
|
+
|
22
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
23
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
24
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
|
25
|
+
|
26
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
27
|
+
`git ls-files -z`
|
28
|
+
.split("\x0")
|
29
|
+
.reject { |f| f.match(%r{^(test|spec|features)/}) }
|
30
|
+
end
|
31
|
+
|
32
|
+
spec.bindir = 'exe'
|
33
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
34
|
+
spec.require_paths = ['lib']
|
35
|
+
|
36
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
37
|
+
spec.add_development_dependency 'byebug', '~> 11.0'
|
38
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
39
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
40
|
+
spec.add_development_dependency 'rubocop', '~> 0.78'
|
41
|
+
spec.add_development_dependency 'rubocop-rspec', '~> 1.37'
|
42
|
+
spec.add_development_dependency 'simplecov', '~> 0.10'
|
43
|
+
|
44
|
+
spec.add_dependency 'colorize', '~> 0.8'
|
45
|
+
end
|