inspec 1.50.1 → 1.51.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/CHANGELOG.md +35 -19
- data/docs/resources/docker_service.md.erb +107 -0
- data/docs/resources/filesystem.md.erb +39 -0
- data/docs/resources/ini.md.erb +5 -4
- data/docs/resources/pip.md.erb +7 -0
- data/lib/inspec/profile_context.rb +1 -0
- data/lib/inspec/resource.rb +2 -0
- data/lib/inspec/version.rb +1 -1
- data/lib/resources/docker.rb +57 -23
- data/lib/resources/docker_container.rb +14 -36
- data/lib/resources/docker_image.rb +8 -46
- data/lib/resources/docker_object.rb +57 -0
- data/lib/resources/docker_service.rb +94 -0
- data/lib/resources/filesystem.rb +31 -0
- data/lib/resources/grub_conf.rb +65 -25
- data/lib/resources/security_policy.rb +19 -4
- data/lib/resources/service.rb +11 -1
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e4952aa70e173665e538c1e9a462da104c533ad1
|
4
|
+
data.tar.gz: a2b79c2109ade3f8f96afbb95104a71ea9d8b16c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e5444459f580693c6cce709ffb6cb99e47d817e1ddd35064a1636a299899165da235197640edb3cc2766d5b93d8fae7194996e731303c63df69f057527e02555
|
7
|
+
data.tar.gz: c866512ba5541fcb0b4dc2fb44c47f3ea22233b9d80590894e646e654f649160fbe0042dabb48941a7b500a709ee1c0d5f73c1725806083ce85713593d0b9939
|
data/CHANGELOG.md
CHANGED
@@ -1,33 +1,50 @@
|
|
1
1
|
# Change Log
|
2
2
|
<!-- usage documentation: http://expeditor-docs.es.chef.io/configuration/changelog/ -->
|
3
|
-
<!-- latest_release
|
4
|
-
##
|
3
|
+
<!-- latest_release 1.51.0 -->
|
4
|
+
## [v1.51.0](https://github.com/chef/inspec/tree/v1.51.0) (2018-01-25)
|
5
5
|
|
6
|
-
####
|
7
|
-
-
|
6
|
+
#### New Resources
|
7
|
+
- filesystem resource: inspect linux filesystems [#2441](https://github.com/chef/inspec/pull/2441) ([tarcinil](https://github.com/tarcinil))
|
8
8
|
<!-- latest_release -->
|
9
9
|
|
10
|
-
<!-- release_rollup since=1.
|
11
|
-
### Changes since 1.
|
12
|
-
|
13
|
-
#### Bug Fixes
|
14
|
-
- http resource: make header keys case insensitive [#2457](https://github.com/chef/inspec/pull/2457) ([adamleff](https://github.com/adamleff)) <!-- 1.49.10 -->
|
15
|
-
- package resource: fix NilClass errors on arch linux [#2437](https://github.com/chef/inspec/pull/2437) ([jerryaldrichiii](https://github.com/jerryaldrichiii)) <!-- 1.49.8 -->
|
16
|
-
- firewalld resource: prepend rule string only when necessary [#2430](https://github.com/chef/inspec/pull/2430) ([tarcinil](https://github.com/tarcinil)) <!-- 1.49.6 -->
|
10
|
+
<!-- release_rollup since=1.50.1 -->
|
11
|
+
### Changes since 1.50.1 release
|
17
12
|
|
18
13
|
#### Enhancements
|
19
|
-
-
|
20
|
-
|
14
|
+
- Update security_policy resource to return Names, not SIDs [#2462](https://github.com/chef/inspec/pull/2462) ([ViolentOr](https://github.com/ViolentOr)) <!-- 1.50.5 -->
|
15
|
+
|
16
|
+
#### New Resources
|
17
|
+
- filesystem resource: inspect linux filesystems [#2441](https://github.com/chef/inspec/pull/2441) ([tarcinil](https://github.com/tarcinil)) <!-- 1.51.0 -->
|
18
|
+
- new docker_service resource to inspect Docker Swarm services [#2456](https://github.com/chef/inspec/pull/2456) ([mattlqx](https://github.com/mattlqx)) <!-- 1.50.4 -->
|
21
19
|
|
22
20
|
#### Merged Pull Requests
|
23
|
-
-
|
24
|
-
|
25
|
-
|
26
|
-
-
|
27
|
-
-
|
21
|
+
- Sort library files before loading them so load order is predictable [#2475](https://github.com/chef/inspec/pull/2475) ([clintoncwolfe](https://github.com/clintoncwolfe)) <!-- 1.50.3 -->
|
22
|
+
|
23
|
+
#### Bug Fixes
|
24
|
+
- service resource: attempt a SysV fallback if SystemD unit file is not found [#2473](https://github.com/chef/inspec/pull/2473) ([jerryaldrichiii](https://github.com/jerryaldrichiii)) <!-- 1.50.6 -->
|
25
|
+
- grub_conf resource: fix menuentry detection [#2408](https://github.com/chef/inspec/pull/2408) ([jerryaldrichiii](https://github.com/jerryaldrichiii)) <!-- 1.50.2 -->
|
28
26
|
<!-- release_rollup -->
|
29
27
|
|
30
28
|
<!-- latest_stable_release -->
|
29
|
+
## [v1.50.1](https://github.com/chef/inspec/tree/v1.50.1) (2018-01-17)
|
30
|
+
|
31
|
+
#### Enhancements
|
32
|
+
- mssql_session resource: add port parameter [#2429](https://github.com/chef/inspec/pull/2429) ([tarcinil](https://github.com/tarcinil))
|
33
|
+
- xml resource: support fetching attributes [#2423](https://github.com/chef/inspec/pull/2423) ([tarcinil](https://github.com/tarcinil))
|
34
|
+
|
35
|
+
#### Bug Fixes
|
36
|
+
- firewalld resource: prepend rule string only when necessary [#2430](https://github.com/chef/inspec/pull/2430) ([tarcinil](https://github.com/tarcinil))
|
37
|
+
- package resource: fix NilClass errors on arch linux [#2437](https://github.com/chef/inspec/pull/2437) ([jerryaldrichiii](https://github.com/jerryaldrichiii))
|
38
|
+
- http resource: make header keys case insensitive [#2457](https://github.com/chef/inspec/pull/2457) ([adamleff](https://github.com/adamleff))
|
39
|
+
|
40
|
+
#### Merged Pull Requests
|
41
|
+
- Fix package manager detection on Arch Linux [#2436](https://github.com/chef/inspec/pull/2436) ([jerryaldrichiii](https://github.com/jerryaldrichiii))
|
42
|
+
- Update the inspec support check to warn to stderr. [#2446](https://github.com/chef/inspec/pull/2446) ([jquick](https://github.com/jquick))
|
43
|
+
- Bump Omnibus Ruby (and Travis Rubies) to 2.4.3 [#2452](https://github.com/chef/inspec/pull/2452) ([adamleff](https://github.com/adamleff))
|
44
|
+
- Bump minor version [#2465](https://github.com/chef/inspec/pull/2465) ([adamleff](https://github.com/adamleff))
|
45
|
+
- Bump version manually to trigger Habitat build [#2466](https://github.com/chef/inspec/pull/2466) ([adamleff](https://github.com/adamleff))
|
46
|
+
<!-- latest_stable_release -->
|
47
|
+
|
31
48
|
## [v1.49.2](https://github.com/chef/inspec/tree/v1.49.2) (2018-01-04)
|
32
49
|
|
33
50
|
#### Enhancements
|
@@ -51,7 +68,6 @@
|
|
51
68
|
#### Merged Pull Requests
|
52
69
|
- Split unit tests from functional [#2391](https://github.com/chef/inspec/pull/2391) ([adamleff](https://github.com/adamleff))
|
53
70
|
- Bump minor version and cleanup changelog for release [#2440](https://github.com/chef/inspec/pull/2440) ([adamleff](https://github.com/adamleff))
|
54
|
-
<!-- latest_stable_release -->
|
55
71
|
|
56
72
|
## [v1.48.0](https://github.com/chef/inspec/tree/v1.48.0) (2017-12-07)
|
57
73
|
|
@@ -0,0 +1,107 @@
|
|
1
|
+
---
|
2
|
+
title: About the docker_service Resource
|
3
|
+
---
|
4
|
+
|
5
|
+
# docker_service
|
6
|
+
|
7
|
+
Use the `docker_service` InSpec audit resource to verify a docker swarm service.
|
8
|
+
|
9
|
+
<br>
|
10
|
+
|
11
|
+
## Syntax
|
12
|
+
|
13
|
+
A `docker_service` resource block declares the service by name:
|
14
|
+
|
15
|
+
describe docker_service('foo') do
|
16
|
+
it { should exist }
|
17
|
+
its('id') { should eq '2ghswegspre1' }
|
18
|
+
its('repo') { should eq 'alpine' }
|
19
|
+
its('tag') { should eq 'latest' }
|
20
|
+
end
|
21
|
+
|
22
|
+
The resource allows you to pass in a service id:
|
23
|
+
|
24
|
+
describe docker_service(id: '2ghswegspre1') do
|
25
|
+
...
|
26
|
+
end
|
27
|
+
|
28
|
+
You can also pass in the fully-qualified image:
|
29
|
+
|
30
|
+
describe docker_service(image: 'localhost:5000/alpine:latest') do
|
31
|
+
...
|
32
|
+
end
|
33
|
+
|
34
|
+
<br>
|
35
|
+
|
36
|
+
## Examples
|
37
|
+
|
38
|
+
The following examples show how to use this InSpec `docker_service` resource.
|
39
|
+
|
40
|
+
### Test a docker service
|
41
|
+
|
42
|
+
describe docker_service('foo') do
|
43
|
+
it { should exist }
|
44
|
+
its('id') { should eq '2ghswegspre1' }
|
45
|
+
its('repo') { should eq 'alpine' }
|
46
|
+
its('tag') { should eq 'latest' }
|
47
|
+
end
|
48
|
+
|
49
|
+
<br>
|
50
|
+
|
51
|
+
## Matchers
|
52
|
+
|
53
|
+
This InSpec audit resource has the following matchers. For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/).
|
54
|
+
|
55
|
+
### exist
|
56
|
+
|
57
|
+
The `exist` matcher tests if the image is available on the node:
|
58
|
+
|
59
|
+
it { should exist }
|
60
|
+
|
61
|
+
### id
|
62
|
+
|
63
|
+
The `id` matcher returns the service id:
|
64
|
+
|
65
|
+
its('id') { should eq '2ghswegspre1' }
|
66
|
+
|
67
|
+
### image
|
68
|
+
|
69
|
+
The `image` matcher tests the value of the image. It is a combination of `repository:tag`:
|
70
|
+
|
71
|
+
its('image') { should eq 'alpine:latest' }
|
72
|
+
|
73
|
+
### mode
|
74
|
+
|
75
|
+
The `mode` matcher tests the value of the service mode:
|
76
|
+
|
77
|
+
its('mode') { should eq 'replicated' }
|
78
|
+
|
79
|
+
### name
|
80
|
+
|
81
|
+
The `name` matcher tests the value of the service name:
|
82
|
+
|
83
|
+
its('name') { should eq 'foo' }
|
84
|
+
|
85
|
+
### ports
|
86
|
+
|
87
|
+
The `ports` matcher tests the value of the service's published ports:
|
88
|
+
|
89
|
+
its('ports') { should include '*:8000->8000/tcp' }
|
90
|
+
|
91
|
+
### repo
|
92
|
+
|
93
|
+
The `repo` matcher tests the value of the repository name:
|
94
|
+
|
95
|
+
its('repo') { should eq 'alpine' }
|
96
|
+
|
97
|
+
### replicas
|
98
|
+
|
99
|
+
The `replicas` matcher tests the value of the service's replica count:
|
100
|
+
|
101
|
+
its('replicas') { should eq '3/3' }
|
102
|
+
|
103
|
+
### tag
|
104
|
+
|
105
|
+
The `tag` matcher tests the value of image tag:
|
106
|
+
|
107
|
+
its('tag') { should eq 'latest' }
|
@@ -0,0 +1,39 @@
|
|
1
|
+
---
|
2
|
+
title: About the filesystem Resource
|
3
|
+
---
|
4
|
+
|
5
|
+
# filesystem
|
6
|
+
|
7
|
+
Use the `filesystem` InSpec resource to audit filesystem disk space usage
|
8
|
+
<br>
|
9
|
+
|
10
|
+
## Syntax
|
11
|
+
|
12
|
+
A `filesystem` resource block declares tests for disk space in a partion:
|
13
|
+
|
14
|
+
describe filesystem('/') do
|
15
|
+
its('size') { should be >= 32000 }
|
16
|
+
end
|
17
|
+
|
18
|
+
where
|
19
|
+
|
20
|
+
* `filesystem('/')` states that it will be looking at the root (/) partition
|
21
|
+
* `size` is measured in megabytes (MB)
|
22
|
+
|
23
|
+
<br>
|
24
|
+
|
25
|
+
## Examples
|
26
|
+
|
27
|
+
The following examples show how to use this InSpec audit resource.
|
28
|
+
|
29
|
+
### Test if the root partition is greater thank 32000 MB
|
30
|
+
|
31
|
+
describe filesystem('/') do
|
32
|
+
its('size') { should be >= 32000 }
|
33
|
+
end
|
34
|
+
|
35
|
+
<br>
|
36
|
+
|
37
|
+
## Matchers
|
38
|
+
|
39
|
+
For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/).
|
data/docs/resources/ini.md.erb
CHANGED
@@ -52,13 +52,14 @@ The following examples show how to use this InSpec audit resource.
|
|
52
52
|
|
53
53
|
For example, a PHP INI file located at contains the following settings:
|
54
54
|
|
55
|
-
|
56
|
-
|
55
|
+
[mail function]
|
56
|
+
SMTP = smtp.gmail.com
|
57
|
+
smtp_port = 465
|
57
58
|
|
58
59
|
and can be tested like this:
|
59
60
|
|
60
|
-
describe ini(/etc/php5/apache2/php.ini) do
|
61
|
-
its('smtp_port') { should eq('465') }
|
61
|
+
describe ini('/etc/php5/apache2/php.ini') do
|
62
|
+
its('mail function.smtp_port') { should eq('465') }
|
62
63
|
end
|
63
64
|
|
64
65
|
<br>
|
data/docs/resources/pip.md.erb
CHANGED
@@ -40,6 +40,13 @@ The following examples show how to use this InSpec audit resource.
|
|
40
40
|
its('version') { should eq '2.8' }
|
41
41
|
end
|
42
42
|
|
43
|
+
### Test packages installed into a non-default location (e.g. virtualenv) by passing a custom path to pip executable
|
44
|
+
|
45
|
+
describe pip('Jinja2', '/path/to/bin/pip') do
|
46
|
+
it { should be_installed }
|
47
|
+
its('version') { should eq '2.8' }
|
48
|
+
end
|
49
|
+
|
43
50
|
<br>
|
44
51
|
|
45
52
|
## Matchers
|
@@ -116,6 +116,7 @@ module Inspec
|
|
116
116
|
lib_prefix = 'libraries' + File::SEPARATOR
|
117
117
|
autoloads = []
|
118
118
|
|
119
|
+
libs.sort_by! { |l| l[1] } # Sort on source path so load order is deterministic
|
119
120
|
libs.each do |content, source, line|
|
120
121
|
path = source
|
121
122
|
if source.start_with?(lib_prefix)
|
data/lib/inspec/resource.rb
CHANGED
@@ -94,12 +94,14 @@ require 'resources/directory'
|
|
94
94
|
require 'resources/docker'
|
95
95
|
require 'resources/docker_container'
|
96
96
|
require 'resources/docker_image'
|
97
|
+
require 'resources/docker_service'
|
97
98
|
require 'resources/elasticsearch'
|
98
99
|
require 'resources/etc_fstab'
|
99
100
|
require 'resources/etc_group'
|
100
101
|
require 'resources/etc_hosts_allow_deny'
|
101
102
|
require 'resources/etc_hosts'
|
102
103
|
require 'resources/file'
|
104
|
+
require 'resources/filesystem'
|
103
105
|
require 'resources/firewalld'
|
104
106
|
require 'resources/gem'
|
105
107
|
require 'resources/groups'
|
data/lib/inspec/version.rb
CHANGED
data/lib/resources/docker.rb
CHANGED
@@ -59,6 +59,25 @@ module Inspec::Resources
|
|
59
59
|
end
|
60
60
|
end
|
61
61
|
|
62
|
+
class DockerServiceFilter
|
63
|
+
filter = FilterTable.create
|
64
|
+
filter.add_accessor(:where)
|
65
|
+
.add_accessor(:entries)
|
66
|
+
.add(:ids, field: 'id')
|
67
|
+
.add(:names, field: 'name')
|
68
|
+
.add(:modes, field: 'mode')
|
69
|
+
.add(:replicas, field: 'replicas')
|
70
|
+
.add(:images, field: 'image')
|
71
|
+
.add(:ports, field: 'ports')
|
72
|
+
.add(:exists?) { |x| !x.entries.empty? }
|
73
|
+
filter.connect(self, :services)
|
74
|
+
|
75
|
+
attr_reader :services
|
76
|
+
def initialize(services)
|
77
|
+
@services = services
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
62
81
|
# This resource helps to parse information from the docker host
|
63
82
|
# For compatability with Serverspec we also offer the following resouses:
|
64
83
|
# - docker_container
|
@@ -79,6 +98,10 @@ module Inspec::Resources
|
|
79
98
|
its('repositories') { should_not include 'inssecure_image' }
|
80
99
|
end
|
81
100
|
|
101
|
+
describe docker.services do
|
102
|
+
its('images') { should_not include 'inssecure_image' }
|
103
|
+
end
|
104
|
+
|
82
105
|
describe docker.version do
|
83
106
|
its('Server.Version') { should cmp >= '1.12'}
|
84
107
|
its('Client.Version') { should cmp >= '1.12'}
|
@@ -105,6 +128,10 @@ module Inspec::Resources
|
|
105
128
|
DockerImageFilter.new(parse_images)
|
106
129
|
end
|
107
130
|
|
131
|
+
def services
|
132
|
+
DockerServiceFilter.new(parse_services)
|
133
|
+
end
|
134
|
+
|
108
135
|
def version
|
109
136
|
return @version if defined?(@version)
|
110
137
|
data = {}
|
@@ -142,48 +169,55 @@ module Inspec::Resources
|
|
142
169
|
|
143
170
|
private
|
144
171
|
|
145
|
-
def
|
146
|
-
# @see https://github.com/moby/moby/issues/20625, works for docker 1.13+
|
147
|
-
# raw_containers = inspec.command('docker ps -a --no-trunc --format \'{{ json . }}\'').stdout
|
148
|
-
# therefore we stick with older approach
|
149
|
-
labels = %w{Command CreatedAt ID Image Labels Mounts Names Ports RunningFor Size Status}
|
150
|
-
|
151
|
-
# Networks LocalVolumes work with 1.13+ only
|
152
|
-
if !version.empty? && Gem::Version.new(version['Client']['Version']) >= Gem::Version.new('1.13')
|
153
|
-
labels.push('Networks')
|
154
|
-
labels.push('LocalVolumes')
|
155
|
-
end
|
172
|
+
def parse_json_command(labels, subcommand)
|
156
173
|
# build command
|
157
174
|
format = labels.map { |label| "\"#{label}\": {{json .#{label}}}" }
|
158
|
-
|
159
|
-
|
175
|
+
raw = inspec.command("docker #{subcommand} --format '{#{format.join(', ')}}'").stdout
|
176
|
+
output = []
|
160
177
|
# since docker is not outputting valid json, we need to parse each row
|
161
|
-
|
162
|
-
j = JSON.parse(entry)
|
178
|
+
raw.each_line { |entry|
|
163
179
|
# convert all keys to lower_case to work well with ruby and filter table
|
164
|
-
j =
|
180
|
+
j = JSON.parse(entry).map { |k, v|
|
165
181
|
[k.downcase, v]
|
166
182
|
}.to_h
|
167
183
|
|
168
184
|
# ensure all keys are there
|
169
|
-
j =
|
185
|
+
j = ensure_keys(j, labels)
|
170
186
|
|
171
187
|
# strip off any linked container names
|
172
188
|
# Depending on how it was linked, the actual container name may come before
|
173
189
|
# or after the link information, so we'll just look for the first name that
|
174
190
|
# does not include a slash since that is not a valid character in a container name
|
175
|
-
j['names'] = j['names'].split(',').find { |c| !c.include?('/') }
|
191
|
+
j['names'] = j['names'].split(',').find { |c| !c.include?('/') } if j.key?('names')
|
176
192
|
|
177
|
-
|
193
|
+
output.push(j)
|
178
194
|
}
|
179
|
-
|
195
|
+
output
|
180
196
|
rescue JSON::ParserError => _e
|
181
|
-
warn
|
197
|
+
warn "Could not parse `docker #{subcommand}` output"
|
182
198
|
[]
|
183
199
|
end
|
184
200
|
|
185
|
-
def
|
186
|
-
|
201
|
+
def parse_containers
|
202
|
+
# @see https://github.com/moby/moby/issues/20625, works for docker 1.13+
|
203
|
+
# raw_containers = inspec.command('docker ps -a --no-trunc --format \'{{ json . }}\'').stdout
|
204
|
+
# therefore we stick with older approach
|
205
|
+
labels = %w{Command CreatedAt ID Image Labels Mounts Names Ports RunningFor Size Status}
|
206
|
+
|
207
|
+
# Networks LocalVolumes work with 1.13+ only
|
208
|
+
if !version.empty? && Gem::Version.new(version['Client']['Version']) >= Gem::Version.new('1.13')
|
209
|
+
labels.push('Networks')
|
210
|
+
labels.push('LocalVolumes')
|
211
|
+
end
|
212
|
+
parse_json_command(labels, 'ps -a --no-trunc')
|
213
|
+
end
|
214
|
+
|
215
|
+
def parse_services
|
216
|
+
parse_json_command(%w{ID Name Mode Replicas Image Ports}, 'service ls')
|
217
|
+
end
|
218
|
+
|
219
|
+
def ensure_keys(entry, labels)
|
220
|
+
labels.each { |key|
|
187
221
|
entry[key.downcase] = nil if !entry.key?(key.downcase)
|
188
222
|
}
|
189
223
|
entry
|
@@ -6,8 +6,12 @@
|
|
6
6
|
# author: Patrick Muench
|
7
7
|
# author: Dominik Richter
|
8
8
|
|
9
|
+
require_relative 'docker_object'
|
10
|
+
|
9
11
|
module Inspec::Resources
|
10
12
|
class DockerContainer < Inspec.resource(1)
|
13
|
+
include Inspec::Resources::DockerObject
|
14
|
+
|
11
15
|
name 'docker_container'
|
12
16
|
desc ''
|
13
17
|
example "
|
@@ -37,55 +41,39 @@ module Inspec::Resources
|
|
37
41
|
end
|
38
42
|
end
|
39
43
|
|
40
|
-
def exist?
|
41
|
-
container_info.exists?
|
42
|
-
end
|
43
|
-
|
44
|
-
# is allways returning the full id
|
45
|
-
def id
|
46
|
-
container_info.ids[0] if container_info.entries.length == 1
|
47
|
-
end
|
48
|
-
|
49
44
|
def running?
|
50
|
-
status.downcase.start_with?('up') if
|
45
|
+
status.downcase.start_with?('up') if object_info.entries.length == 1
|
51
46
|
end
|
52
47
|
|
53
48
|
def status
|
54
|
-
|
49
|
+
object_info.status[0] if object_info.entries.length == 1
|
55
50
|
end
|
56
51
|
|
57
52
|
def labels
|
58
|
-
|
53
|
+
object_info.labels[0] if object_info.entries.length == 1
|
59
54
|
end
|
60
55
|
|
61
56
|
def ports
|
62
|
-
|
57
|
+
object_info.ports[0] if object_info.entries.length == 1
|
63
58
|
end
|
64
59
|
|
65
60
|
def command
|
66
|
-
return unless
|
61
|
+
return unless object_info.entries.length == 1
|
67
62
|
|
68
|
-
cmd =
|
63
|
+
cmd = object_info.commands[0]
|
69
64
|
cmd.slice(1, cmd.length - 2)
|
70
65
|
end
|
71
66
|
|
72
67
|
def image
|
73
|
-
|
68
|
+
object_info.images[0] if object_info.entries.length == 1
|
74
69
|
end
|
75
70
|
|
76
71
|
def repo
|
77
|
-
|
78
|
-
if image.include?('/') # host:port/ubuntu:latest
|
79
|
-
repo_part, image_part = image.split('/') # host:port, ubuntu:latest
|
80
|
-
repo_part + '/' + image_part.split(':')[0] # host:port + / + ubuntu
|
81
|
-
else
|
82
|
-
image_name_from_image.split(':')[0]
|
83
|
-
end
|
72
|
+
parse_components_from_image(image)[:repo] if object_info.entries.size == 1
|
84
73
|
end
|
85
74
|
|
86
75
|
def tag
|
87
|
-
|
88
|
-
image_name_from_image.split(':')[1]
|
76
|
+
parse_components_from_image(image)[:tag] if object_info.entries.size == 1
|
89
77
|
end
|
90
78
|
|
91
79
|
def to_s
|
@@ -95,17 +83,7 @@ module Inspec::Resources
|
|
95
83
|
|
96
84
|
private
|
97
85
|
|
98
|
-
def
|
99
|
-
return if image.nil?
|
100
|
-
# possible image names include:
|
101
|
-
# alpine
|
102
|
-
# ubuntu:14.04
|
103
|
-
# repo.example.com:5000/ubuntu
|
104
|
-
# repo.example.com:5000/ubuntu:1404
|
105
|
-
image.include?('/') ? image.split('/')[1] : image
|
106
|
-
end
|
107
|
-
|
108
|
-
def container_info
|
86
|
+
def object_info
|
109
87
|
return @info if defined?(@info)
|
110
88
|
opts = @opts
|
111
89
|
@info = inspec.docker.containers.where { names == opts[:name] || (!id.nil? && !opts[:id].nil? && (id == opts[:id] || id.start_with?(opts[:id]))) }
|
@@ -6,8 +6,12 @@
|
|
6
6
|
# author: Patrick Muench
|
7
7
|
# author: Dominik Richter
|
8
8
|
|
9
|
+
require_relative 'docker_object'
|
10
|
+
|
9
11
|
module Inspec::Resources
|
10
12
|
class DockerImage < Inspec.resource(1)
|
13
|
+
include Inspec::Resources::DockerObject
|
14
|
+
|
11
15
|
name 'docker_image'
|
12
16
|
desc ''
|
13
17
|
example "
|
@@ -35,24 +39,16 @@ module Inspec::Resources
|
|
35
39
|
@opts = sanitize_options(o)
|
36
40
|
end
|
37
41
|
|
38
|
-
def exist?
|
39
|
-
image_info.exists?
|
40
|
-
end
|
41
|
-
|
42
|
-
def id
|
43
|
-
image_info.ids[0] if image_info.entries.size == 1
|
44
|
-
end
|
45
|
-
|
46
42
|
def image
|
47
|
-
"#{repo}:#{tag}" if
|
43
|
+
"#{repo}:#{tag}" if object_info.entries.size == 1
|
48
44
|
end
|
49
45
|
|
50
46
|
def repo
|
51
|
-
|
47
|
+
object_info.repositories[0] if object_info.entries.size == 1
|
52
48
|
end
|
53
49
|
|
54
50
|
def tag
|
55
|
-
|
51
|
+
object_info.tags[0] if object_info.entries.size == 1
|
56
52
|
end
|
57
53
|
|
58
54
|
def to_s
|
@@ -79,46 +75,12 @@ module Inspec::Resources
|
|
79
75
|
opts
|
80
76
|
end
|
81
77
|
|
82
|
-
def
|
78
|
+
def object_info
|
83
79
|
return @info if defined?(@info)
|
84
80
|
opts = @opts
|
85
81
|
@info = inspec.docker.images.where {
|
86
82
|
(repository == opts[:repo] && tag == opts[:tag]) || (!id.nil? && !opts[:id].nil? && (id == opts[:id] || id.start_with?(opts[:id])))
|
87
83
|
}
|
88
84
|
end
|
89
|
-
|
90
|
-
def parse_components_from_image(image_string)
|
91
|
-
# if the user did not supply an image string, they likely supplied individual
|
92
|
-
# option parameters, such as repo and tag. Return empty data back to the caller.
|
93
|
-
return {} if image_string.nil?
|
94
|
-
|
95
|
-
first_colon = image_string.index(':') || -1
|
96
|
-
first_slash = image_string.index('/') || -1
|
97
|
-
|
98
|
-
if image_string.count(':') == 2
|
99
|
-
# If there are two colons in the image string, it contains a repo-with-port and a tag.
|
100
|
-
# example: localhost:5000/chef/inspec:1.46.3
|
101
|
-
partitioned_string = image_string.rpartition(':')
|
102
|
-
repo = partitioned_string.first
|
103
|
-
tag = partitioned_string.last
|
104
|
-
elsif image_string.count(':') == 1 && first_colon < first_slash
|
105
|
-
# If there's one colon in the image string, and it comes before a forward-slash,
|
106
|
-
# it contains a repo-with-port but no tag.
|
107
|
-
# example: localhost:5000/ubuntu
|
108
|
-
repo = image_string
|
109
|
-
tag = nil
|
110
|
-
else
|
111
|
-
# If there's one colon in the image string and it doesn't preceed a slash, or if
|
112
|
-
# there is no colon at all, then it separates the repo from the tag, if there is a tag.
|
113
|
-
# example: chef/inspec:1.46.3
|
114
|
-
# example: chef/inspec
|
115
|
-
# example: ubuntu:14.04
|
116
|
-
repo, tag = image_string.split(':')
|
117
|
-
end
|
118
|
-
|
119
|
-
# return the repo and tag parsed from the string, which can be merged into
|
120
|
-
# the rest of the user-supplied options
|
121
|
-
{ repo: repo, tag: tag }
|
122
|
-
end
|
123
85
|
end
|
124
86
|
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
#
|
3
|
+
# Copyright 2017, Christoph Hartmann
|
4
|
+
#
|
5
|
+
# author: Christoph Hartmann
|
6
|
+
# author: Patrick Muench
|
7
|
+
# author: Dominik Richter
|
8
|
+
# author: Matt Kulka
|
9
|
+
|
10
|
+
module Inspec::Resources::DockerObject
|
11
|
+
def exist?
|
12
|
+
object_info.exists?
|
13
|
+
end
|
14
|
+
|
15
|
+
def id
|
16
|
+
object_info.ids[0] if object_info.entries.size == 1
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def parse_components_from_image(image_string)
|
22
|
+
# if the user did not supply an image string, they likely supplied individual
|
23
|
+
# option parameters, such as repo and tag. Return empty data back to the caller.
|
24
|
+
return {} if image_string.nil?
|
25
|
+
|
26
|
+
first_colon = image_string.index(':') || -1
|
27
|
+
first_slash = image_string.index('/') || -1
|
28
|
+
|
29
|
+
if image_string.count(':') == 2
|
30
|
+
# If there are two colons in the image string, it contains a repo-with-port and a tag.
|
31
|
+
# example: localhost:5000/chef/inspec:1.46.3
|
32
|
+
partitioned_string = image_string.rpartition(':')
|
33
|
+
repo = partitioned_string.first
|
34
|
+
tag = partitioned_string.last
|
35
|
+
image_name = repo.split('/')[1..-1].join
|
36
|
+
elsif image_string.count(':') == 1 && first_colon < first_slash
|
37
|
+
# If there's one colon in the image string, and it comes before a forward-slash,
|
38
|
+
# it contains a repo-with-port but no tag.
|
39
|
+
# example: localhost:5000/ubuntu
|
40
|
+
repo = image_string
|
41
|
+
tag = nil
|
42
|
+
image_name = repo.split('/')[1..-1].join
|
43
|
+
else
|
44
|
+
# If there's one colon in the image string and it doesn't preceed a slash, or if
|
45
|
+
# there is no colon at all, then it separates the repo from the tag, if there is a tag.
|
46
|
+
# example: chef/inspec:1.46.3
|
47
|
+
# example: chef/inspec
|
48
|
+
# example: ubuntu:14.04
|
49
|
+
repo, tag = image_string.split(':')
|
50
|
+
image_name = repo
|
51
|
+
end
|
52
|
+
|
53
|
+
# return the repo, image_name and tag parsed from the string, which can be merged into
|
54
|
+
# the rest of the user-supplied options
|
55
|
+
{ repo: repo, image_name: image_name, tag: tag }
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
#
|
3
|
+
# Copyright 2017, Christoph Hartmann
|
4
|
+
#
|
5
|
+
# author: Christoph Hartmann
|
6
|
+
# author: Patrick Muench
|
7
|
+
# author: Dominik Richter
|
8
|
+
# author: Matt Kulka
|
9
|
+
|
10
|
+
require_relative 'docker_object'
|
11
|
+
|
12
|
+
module Inspec::Resources
|
13
|
+
class DockerService < Inspec.resource(1)
|
14
|
+
include Inspec::Resources::DockerObject
|
15
|
+
|
16
|
+
name 'docker_service'
|
17
|
+
desc 'Swarm-mode service'
|
18
|
+
example "
|
19
|
+
describe docker_service('service1') do
|
20
|
+
it { should exist }
|
21
|
+
its('id') { should_not eq '' }
|
22
|
+
its('image') { should eq 'alpine:latest' }
|
23
|
+
its('repo') { should eq 'alpine' }
|
24
|
+
its('tag') { should eq 'latest' }
|
25
|
+
end
|
26
|
+
|
27
|
+
describe docker_service(id: '4a415e366388') do
|
28
|
+
it { should exist }
|
29
|
+
end
|
30
|
+
|
31
|
+
describe docker_service(image: 'alpine:latest') do
|
32
|
+
it { should exist }
|
33
|
+
end
|
34
|
+
"
|
35
|
+
|
36
|
+
def initialize(opts = {})
|
37
|
+
# do sanitizion of input values
|
38
|
+
o = opts.dup
|
39
|
+
o = { name: opts } if opts.is_a?(String)
|
40
|
+
@opts = sanitize_options(o)
|
41
|
+
end
|
42
|
+
|
43
|
+
def name
|
44
|
+
object_info.names[0] if object_info.entries.size == 1
|
45
|
+
end
|
46
|
+
|
47
|
+
def image
|
48
|
+
object_info.images[0] if object_info.entries.size == 1
|
49
|
+
end
|
50
|
+
|
51
|
+
def image_name
|
52
|
+
parse_components_from_image(image)[:image_name] if object_info.entries.size == 1
|
53
|
+
end
|
54
|
+
|
55
|
+
def repo
|
56
|
+
parse_components_from_image(image)[:repo] if object_info.entries.size == 1
|
57
|
+
end
|
58
|
+
|
59
|
+
def tag
|
60
|
+
parse_components_from_image(image)[:tag] if object_info.entries.size == 1
|
61
|
+
end
|
62
|
+
|
63
|
+
def mode
|
64
|
+
object_info.modes[0] if object_info.entries.size == 1
|
65
|
+
end
|
66
|
+
|
67
|
+
def replicas
|
68
|
+
object_info.replicas[0] if object_info.entries.size == 1
|
69
|
+
end
|
70
|
+
|
71
|
+
def ports
|
72
|
+
object_info.ports[0] if object_info.entries.size == 1
|
73
|
+
end
|
74
|
+
|
75
|
+
def to_s
|
76
|
+
service = @opts[:name] || @opts[:id]
|
77
|
+
"Docker Service #{service}"
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def sanitize_options(opts)
|
83
|
+
opts.merge(parse_components_from_image(opts[:image]))
|
84
|
+
end
|
85
|
+
|
86
|
+
def object_info
|
87
|
+
return @info if defined?(@info)
|
88
|
+
opts = @opts
|
89
|
+
@info = inspec.docker.services.where {
|
90
|
+
name == opts[:name] || image == opts[:image] || (!id.nil? && !opts[:id].nil? && (id == opts[:id] || id.start_with?(opts[:id])))
|
91
|
+
}
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Inspec::Resources
|
2
|
+
class FileSystemResource < Inspec.resource(1)
|
3
|
+
name 'filesystem'
|
4
|
+
supports os_family: 'linux'
|
5
|
+
desc 'Use the filesystem InSpec resource to test file system'
|
6
|
+
example "
|
7
|
+
describe filesystem('/') do
|
8
|
+
its('size') { should be >= 32000 }
|
9
|
+
end
|
10
|
+
"
|
11
|
+
attr_reader :partition
|
12
|
+
|
13
|
+
def initialize(partition)
|
14
|
+
@partition = partition
|
15
|
+
end
|
16
|
+
|
17
|
+
def size
|
18
|
+
@size ||= begin
|
19
|
+
cmd = inspec.command("df #{partition} --output=size")
|
20
|
+
raise Inspec::Exceptions::ResourceFailed, "Unable to get available space for partition #{partition}" if cmd.stdout.nil? || cmd.stdout.empty? || !cmd.exit_status.zero?
|
21
|
+
|
22
|
+
value = cmd.stdout.gsub(/\dK-blocks[\r\n]/, '').strip
|
23
|
+
value.to_i
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_s
|
28
|
+
"Filesystem #{partition}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/resources/grub_conf.rb
CHANGED
@@ -36,6 +36,7 @@ class GrubConfig < Inspec.resource(1)
|
|
36
36
|
elsif os.debian?
|
37
37
|
@conf_path = path || '/boot/grub/grub.cfg'
|
38
38
|
@defaults_path = '/etc/default/grub'
|
39
|
+
@grubenv_path = '/boot/grub2/grubenv'
|
39
40
|
@version = 'grub2'
|
40
41
|
elsif os[:name] == 'amazon'
|
41
42
|
@conf_path = path || '/etc/grub.conf'
|
@@ -52,6 +53,7 @@ class GrubConfig < Inspec.resource(1)
|
|
52
53
|
else
|
53
54
|
@conf_path = path || '/boot/grub2/grub.cfg'
|
54
55
|
@defaults_path = '/etc/default/grub'
|
56
|
+
@grubenv_path = '/boot/grub2/grubenv'
|
55
57
|
@version = 'grub2'
|
56
58
|
end
|
57
59
|
end
|
@@ -71,35 +73,73 @@ class GrubConfig < Inspec.resource(1)
|
|
71
73
|
######################################################################
|
72
74
|
|
73
75
|
def grub2_parse_kernel_lines(content, conf)
|
74
|
-
|
75
|
-
|
76
|
+
menu_entries = extract_menu_entries(content)
|
77
|
+
|
78
|
+
if @kernel == 'default'
|
79
|
+
default_menu_entry(menu_entries, conf['GRUB_DEFAULT'])
|
80
|
+
else
|
81
|
+
menu_entries.find { |entry| entry['name'] == @kernel }
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def extract_menu_entries(content)
|
86
|
+
menu_entries = []
|
87
|
+
|
76
88
|
lines = content.split("\n")
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
89
|
+
lines.each_with_index do |line, index|
|
90
|
+
next unless line =~ /^menuentry\s+.*/
|
91
|
+
entry = {}
|
92
|
+
entry['insmod'] = []
|
93
|
+
|
94
|
+
# Extract name from menuentry line
|
95
|
+
capture_data = line.match(/(?:^|\s+).*menuentry\s*['|"](.*)['|"]\s*--/)
|
96
|
+
if capture_data.nil? || capture_data.captures[0].nil?
|
97
|
+
raise Inspec::Exceptions::ResourceFailed "Failed to extract menuentry name from #{line}"
|
98
|
+
end
|
99
|
+
|
100
|
+
entry['name'] = capture_data.captures[0]
|
101
|
+
|
102
|
+
# Begin processing from index forward until a `}` line is met
|
103
|
+
lines.drop(index+1).each do |mline|
|
104
|
+
break if mline =~ /^\s*}\s*$/
|
105
|
+
case mline
|
106
|
+
when /(?:^|\s*)initrd.*/
|
107
|
+
entry['initrd'] = mline.split(' ')[1]
|
108
|
+
when /(?:^|\s*)linux.*/
|
109
|
+
entry['kernel'] = mline.split
|
110
|
+
when /(?:^|\s*)set root=.*/
|
111
|
+
entry['root'] = mline.split('=')[1].tr('\'', '')
|
112
|
+
when /(?:^|\s*)insmod.*/
|
113
|
+
entry['insmod'] << mline.split(' ')[1]
|
99
114
|
end
|
100
115
|
end
|
116
|
+
|
117
|
+
menu_entries << entry
|
101
118
|
end
|
102
|
-
|
119
|
+
|
120
|
+
menu_entries
|
121
|
+
end
|
122
|
+
|
123
|
+
def default_menu_entry(menu_entries, default)
|
124
|
+
# If the default entry isn't `saved` then a number is used as an index.
|
125
|
+
# By default this is `0`, which would be the first item in the list.
|
126
|
+
return menu_entries[default.to_i] unless default == 'saved'
|
127
|
+
|
128
|
+
grubenv_contents = inspec.file(@grubenv_path).content
|
129
|
+
|
130
|
+
# The location of the grubenv file is not guaranteed. In the case that
|
131
|
+
# the file does not exist this will return the 0th entry. This will also
|
132
|
+
# return the 0th entry if InSpec lacks permission to read the file. Both
|
133
|
+
# of these reflect the default Grub2 behavior.
|
134
|
+
return menu_entries[0] if grubenv_contents.nil?
|
135
|
+
|
136
|
+
default_name = SimpleConfig.new(grubenv_contents).params['saved_entry']
|
137
|
+
default_entry = menu_entries.select { |k| k['name'] == default_name }[0]
|
138
|
+
return default_entry unless default_entry.nil?
|
139
|
+
|
140
|
+
# It is possible for the saved entry to not be valid . For example, grubenv
|
141
|
+
# not being up to date. If so, the 0th entry is the default.
|
142
|
+
menu_entries[0]
|
103
143
|
end
|
104
144
|
|
105
145
|
###################################################################
|
@@ -74,8 +74,16 @@ module Inspec::Resources
|
|
74
74
|
describe security_policy do
|
75
75
|
its('SeNetworkLogonRight') { should include 'S-1-5-11' }
|
76
76
|
end
|
77
|
+
|
78
|
+
describe security_policy(translate_sid: true) do
|
79
|
+
its('SeNetworkLogonRight') { should include 'NT AUTHORITY\\Authenticated Users' }
|
80
|
+
end
|
77
81
|
"
|
78
82
|
|
83
|
+
def initialize(opts = {})
|
84
|
+
@translate_sid = opts[:translate_sid] || false
|
85
|
+
end
|
86
|
+
|
79
87
|
def content
|
80
88
|
read_content
|
81
89
|
end
|
@@ -142,10 +150,17 @@ module Inspec::Resources
|
|
142
150
|
if val =~ /^\d+$/
|
143
151
|
val.to_i
|
144
152
|
# special handling for SID array
|
145
|
-
elsif val =~
|
146
|
-
|
147
|
-
|
148
|
-
|
153
|
+
elsif val =~ /[,]{0,1}\*\S/
|
154
|
+
if @translate_sid
|
155
|
+
val.split(',').map { |v|
|
156
|
+
object_name = inspec.command("(New-Object System.Security.Principal.SecurityIdentifier(\"#{v.sub('*S', 'S')}\")).Translate( [System.Security.Principal.NTAccount]).Value").stdout.to_s.strip
|
157
|
+
object_name.empty? || object_name.nil? ? v.sub('*S', 'S') : object_name
|
158
|
+
}
|
159
|
+
else
|
160
|
+
val.split(',').map { |v|
|
161
|
+
v.sub('*S', 'S')
|
162
|
+
}
|
163
|
+
end
|
149
164
|
# special handling for string values with "
|
150
165
|
elsif !(m = /^\"(.*)\"$/.match(val)).nil?
|
151
166
|
m[1]
|
data/lib/resources/service.rb
CHANGED
@@ -250,7 +250,17 @@ module Inspec::Resources
|
|
250
250
|
end
|
251
251
|
|
252
252
|
def is_enabled?(service_name)
|
253
|
-
inspec.command("#{service_ctl} is-enabled #{service_name} --quiet")
|
253
|
+
result = inspec.command("#{service_ctl} is-enabled #{service_name} --quiet")
|
254
|
+
return true if result.exit_status == 0
|
255
|
+
|
256
|
+
# Some systems may not have a `.service` file for a particular service
|
257
|
+
# which causes the `systemctl is-enabled` check to fail despite the
|
258
|
+
# service being enabled. In that event we fallback to `sysv_service`.
|
259
|
+
if result.stderr =~ /Failed to get.*No such file or directory/
|
260
|
+
return inspec.sysv_service(service_name).enabled?
|
261
|
+
end
|
262
|
+
|
263
|
+
false
|
254
264
|
end
|
255
265
|
|
256
266
|
def is_active?(service_name)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: inspec
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.51.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Dominik Richter
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-01-
|
11
|
+
date: 2018-01-25 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: train
|
@@ -319,6 +319,7 @@ files:
|
|
319
319
|
- docs/resources/docker.md.erb
|
320
320
|
- docs/resources/docker_container.md.erb
|
321
321
|
- docs/resources/docker_image.md.erb
|
322
|
+
- docs/resources/docker_service.md.erb
|
322
323
|
- docs/resources/elasticsearch.md.erb
|
323
324
|
- docs/resources/etc_fstab.md.erb
|
324
325
|
- docs/resources/etc_group.md.erb
|
@@ -326,6 +327,7 @@ files:
|
|
326
327
|
- docs/resources/etc_hosts_allow.md.erb
|
327
328
|
- docs/resources/etc_hosts_deny.md.erb
|
328
329
|
- docs/resources/file.md.erb
|
330
|
+
- docs/resources/filesystem.md.erb
|
329
331
|
- docs/resources/firewalld.md.erb
|
330
332
|
- docs/resources/gem.md.erb
|
331
333
|
- docs/resources/group.md.erb
|
@@ -571,12 +573,15 @@ files:
|
|
571
573
|
- lib/resources/docker.rb
|
572
574
|
- lib/resources/docker_container.rb
|
573
575
|
- lib/resources/docker_image.rb
|
576
|
+
- lib/resources/docker_object.rb
|
577
|
+
- lib/resources/docker_service.rb
|
574
578
|
- lib/resources/elasticsearch.rb
|
575
579
|
- lib/resources/etc_fstab.rb
|
576
580
|
- lib/resources/etc_group.rb
|
577
581
|
- lib/resources/etc_hosts.rb
|
578
582
|
- lib/resources/etc_hosts_allow_deny.rb
|
579
583
|
- lib/resources/file.rb
|
584
|
+
- lib/resources/filesystem.rb
|
580
585
|
- lib/resources/firewalld.rb
|
581
586
|
- lib/resources/gem.rb
|
582
587
|
- lib/resources/groups.rb
|