entitlements-gitrepo-auditor-plugin 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/VERSION +1 -0
- data/lib/entitlements/auditor/gitrepo.rb +445 -0
- data/lib/entitlements/util/gitrepo.rb +227 -0
- metadata +317 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5d2c6e9e8d300430a3e00d95bb6c3d6ab275e2d226adbfdeb0bc25ebb8491da1
|
4
|
+
data.tar.gz: d52b1b6ec5f60a5ec79040dd8e57b59cc8f951e8cfdb329f9c4aa4992af3d022
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3120c728197899b44e5f712f903834cdc40e2cb2ebaa29f395dee40d7ccf2160f7ae5a8d6a13e5b76bb7eba9c808af607a5f76b782a0b847327f306a132bbd96
|
7
|
+
data.tar.gz: b98e1e33ecccd8914138fc20b40d5cdc4b5101dd1d05acf4640d486837a71e2f18d78be514e5600676c69f1982e7d60dff404aa8c573c58aec1dd98db380838d
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
@@ -0,0 +1,445 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This audit provider dumps a sorted list of member DNs and group metadata to a file in a directory structure,
|
4
|
+
# and when the entitlements run is finished, commits the changes to the git repo.
|
5
|
+
require "base64"
|
6
|
+
|
7
|
+
module Entitlements
|
8
|
+
class Auditor
|
9
|
+
class GitRepo < Entitlements::Auditor::Base
|
10
|
+
include ::Contracts::Core
|
11
|
+
C = ::Contracts
|
12
|
+
|
13
|
+
# Setup method for git repo: this will clone the repo into the configured directory if
|
14
|
+
# it does not exist, or pull the latest changes from the configured directory if it does.
|
15
|
+
# We can also validate that the required parameters are supplied here.
|
16
|
+
#
|
17
|
+
# Takes no arguments.
|
18
|
+
#
|
19
|
+
# Returns nothing.
|
20
|
+
Contract C::None => C::Any
|
21
|
+
def setup
|
22
|
+
validate_options!
|
23
|
+
operation = File.directory?(checkout_directory) ? :pull : :clone
|
24
|
+
|
25
|
+
logger.debug "Preparing #{checkout_directory}"
|
26
|
+
@repo = Entitlements::Util::GitRepo.new(
|
27
|
+
repo: config["repo"],
|
28
|
+
sshkey: Base64.decode64(config["sshkey"]),
|
29
|
+
logger: logger
|
30
|
+
)
|
31
|
+
@repo.github = config["github_override"] if config["github_override"]
|
32
|
+
@repo.send(operation, checkout_directory)
|
33
|
+
@repo.configure(checkout_directory, config["git_name"], config["git_email"])
|
34
|
+
logger.debug "Directory #{checkout_directory} prepared"
|
35
|
+
end
|
36
|
+
|
37
|
+
# Commit method for git repo: this will write out all of the entitlements files to the
|
38
|
+
# configured directory, then do a git commit, and then push to GitHub.
|
39
|
+
#
|
40
|
+
# actions - Array of Entitlements::Models::Action (all requested actions)
|
41
|
+
# successful_actions - Set of DNs (successfully applied actions)
|
42
|
+
# provider_exception - Exception raised by a provider when applying (hopefully nil)
|
43
|
+
#
|
44
|
+
# Returns nothing.
|
45
|
+
Contract C::KeywordArgs[
|
46
|
+
actions: C::ArrayOf[Entitlements::Models::Action],
|
47
|
+
successful_actions: C::SetOf[String],
|
48
|
+
provider_exception: C::Or[nil, Exception]
|
49
|
+
] => C::Any
|
50
|
+
def commit(actions:, successful_actions:, provider_exception:)
|
51
|
+
raise "Must run setup method before running commit method" unless @repo
|
52
|
+
|
53
|
+
sync_changes = {}
|
54
|
+
valid_changes = {}
|
55
|
+
|
56
|
+
commitable_actions = actions_with_membership_change(actions)
|
57
|
+
action_hash = commitable_actions.map { |action| [action.dn, action] }.to_h
|
58
|
+
|
59
|
+
%w[update_files delete_files].each do |m|
|
60
|
+
send(
|
61
|
+
m.to_sym,
|
62
|
+
action_hash: action_hash,
|
63
|
+
successful_actions: successful_actions,
|
64
|
+
sync_changes: sync_changes,
|
65
|
+
valid_changes: valid_changes
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
# If there is anything out-of-sync and the provider did not throw an exception, create
|
70
|
+
# a special sync commit to update things.
|
71
|
+
if sync_changes.any?
|
72
|
+
if provider_exception
|
73
|
+
logger.warn "Not committing #{sync_changes.size} unrecognized change(s) due to provider exception"
|
74
|
+
else
|
75
|
+
logger.warn "Sync changes required: count=#{sync_changes.size}"
|
76
|
+
commit_changes(sync_changes, :sync, commit_message)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# If there are any valid changes, create a commit to update things.
|
81
|
+
if valid_changes.any?
|
82
|
+
logger.debug "Committing #{valid_changes.size} change(s) to git repository"
|
83
|
+
commit_changes(valid_changes, :valid, commit_message)
|
84
|
+
elsif sync_changes.empty?
|
85
|
+
logger.debug "No changes to git repository"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
# The checkout directory from the configuration. Separated out here as a method so
|
92
|
+
# it can be more easily stubbed from tests.
|
93
|
+
#
|
94
|
+
# Takes no arguments.
|
95
|
+
#
|
96
|
+
# Returns a String with the checkout directory.
|
97
|
+
Contract C::None => String
|
98
|
+
def checkout_directory
|
99
|
+
config["checkout_directory"]
|
100
|
+
end
|
101
|
+
|
102
|
+
# Commit message - read from the provider's configuration. (This is so the commit
|
103
|
+
# message can be passed in as an ERB for flexibility.)
|
104
|
+
#
|
105
|
+
# Takes no arguments.
|
106
|
+
#
|
107
|
+
# Returns a String with the commit message.
|
108
|
+
Contract C::None => String
|
109
|
+
def commit_message
|
110
|
+
config.fetch("commit_message")
|
111
|
+
end
|
112
|
+
|
113
|
+
# Check the current calculation object for any added or updated groups. Break changes
|
114
|
+
# apart into sync changes and valid changes.
|
115
|
+
#
|
116
|
+
# action_hash - The hash of actions
|
117
|
+
# successful_actions - Set of DNs that were successfully updated/added/deleted
|
118
|
+
# sync_changes - The hash (will be updated)
|
119
|
+
# valid_changes - The hash (will be updated)
|
120
|
+
#
|
121
|
+
# Returns nothing.
|
122
|
+
Contract C::KeywordArgs[
|
123
|
+
action_hash: C::HashOf[String => Entitlements::Models::Action],
|
124
|
+
successful_actions: C::SetOf[String],
|
125
|
+
sync_changes: C::HashOf[String => C::Or[String, :delete]],
|
126
|
+
valid_changes: C::HashOf[String => C::Or[String, :delete]]
|
127
|
+
] => C::Any
|
128
|
+
def update_files(action_hash:, successful_actions:, sync_changes:, valid_changes:)
|
129
|
+
# Need to add nil entries onto the calculated hash for action == delete. These entries
|
130
|
+
# simply disappear from the calculated hash, but we need to iterate over them too.
|
131
|
+
iterable_hash = Entitlements::Data::Groups::Calculated.to_h.dup
|
132
|
+
action_hash.select { |dn, action| action.change_type == :delete }.each do |dn, _action|
|
133
|
+
iterable_hash[dn] = nil
|
134
|
+
end
|
135
|
+
|
136
|
+
iterable_hash.each do |dn, group|
|
137
|
+
filename = path_from_dn(dn)
|
138
|
+
target_file = File.join(checkout_directory, filename)
|
139
|
+
action = action_hash[dn]
|
140
|
+
|
141
|
+
if action.nil?
|
142
|
+
handle_no_action(sync_changes, valid_changes, filename, target_file, group)
|
143
|
+
elsif action.change_type == :delete
|
144
|
+
handle_delete(sync_changes, valid_changes, action, successful_actions, filename, target_file, dn)
|
145
|
+
elsif action.change_type == :add
|
146
|
+
handle_add(sync_changes, valid_changes, action, successful_actions, filename, target_file, dn, group)
|
147
|
+
elsif action.change_type == :update
|
148
|
+
handle_update(sync_changes, valid_changes, action, successful_actions, filename, target_file, dn, group)
|
149
|
+
else
|
150
|
+
# :nocov:
|
151
|
+
raise "Unhandled condition for action #{action.inspect}"
|
152
|
+
# :nocov:
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Find files that need to be deleted. All files deleted because of actions are handled in the
|
158
|
+
# 'updated_files' method. This method is truly meant for cleanup only.
|
159
|
+
#
|
160
|
+
# action_hash - The hash of actions
|
161
|
+
# successful_actions - Set of DNs that were successfully updated/added/deleted
|
162
|
+
# sync_changes - The hash (will be updated)
|
163
|
+
# valid_changes - The hash (will be updated)
|
164
|
+
#
|
165
|
+
# Returns nothing.
|
166
|
+
Contract C::KeywordArgs[
|
167
|
+
action_hash: C::HashOf[String => Entitlements::Models::Action],
|
168
|
+
successful_actions: C::SetOf[String],
|
169
|
+
sync_changes: C::HashOf[String => C::Or[String, :delete]],
|
170
|
+
valid_changes: C::HashOf[String => C::Or[String, :delete]]
|
171
|
+
] => C::Any
|
172
|
+
def delete_files(action_hash:, successful_actions:, sync_changes:, valid_changes:)
|
173
|
+
Dir.chdir checkout_directory
|
174
|
+
Dir.glob("**/*") do |child|
|
175
|
+
child_file = File.join(checkout_directory, child)
|
176
|
+
next unless File.file?(child_file)
|
177
|
+
next if File.basename(child) == "README.md"
|
178
|
+
|
179
|
+
# !! NOTE !!
|
180
|
+
# Defined actions (:add, :update, :delete) are handled in update_files. This
|
181
|
+
# logic only deals with files that exist and shouldn't and don't have action being
|
182
|
+
# taken upon them.
|
183
|
+
|
184
|
+
child_dn = dn_from_path(child)
|
185
|
+
if Entitlements::Data::Groups::Calculated.to_h.key?(child_dn)
|
186
|
+
# File is supposed to exist and it exists. Do nothing.
|
187
|
+
elsif action_hash[child_dn].nil?
|
188
|
+
# File is not supposed to exist, but it does, and there was no action concerning it.
|
189
|
+
# Set up sync change to delete file.
|
190
|
+
logger.warn "Sync change (delete #{child}) required"
|
191
|
+
sync_changes[child] = :delete
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
# For readability: Handle no-action logic.
|
197
|
+
def handle_no_action(sync_changes, valid_changes, filename, target_file, group)
|
198
|
+
group_expected_contents = group_contents_as_text(group)
|
199
|
+
|
200
|
+
if File.file?(target_file)
|
201
|
+
file_contents = File.read(target_file)
|
202
|
+
unless file_contents == group_expected_contents
|
203
|
+
logger.warn "Sync change (update #{filename}) required"
|
204
|
+
sync_changes[filename] = group_expected_contents
|
205
|
+
end
|
206
|
+
elsif group.member_strings.empty?
|
207
|
+
# The group does not currently exist in the file system nor is it created in the
|
208
|
+
# provider because it has no members. We can skip over this case.
|
209
|
+
else
|
210
|
+
logger.warn "Sync change (create #{filename}) required"
|
211
|
+
sync_changes[filename] = group_expected_contents
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# For readability: Handle 'delete' logic.
|
216
|
+
def handle_delete(sync_changes, valid_changes, action, successful_actions, filename, target_file, dn)
|
217
|
+
group_expected_contents = group_contents_as_text(action.existing)
|
218
|
+
|
219
|
+
if File.file?(target_file)
|
220
|
+
file_contents = File.read(target_file)
|
221
|
+
if successful_actions.member?(dn)
|
222
|
+
if file_contents == group_expected_contents
|
223
|
+
# Good: The file had the correct prior content so it can just be deleted.
|
224
|
+
logger.debug "Valid change (delete #{filename}) queued"
|
225
|
+
valid_changes[filename] = :delete
|
226
|
+
else
|
227
|
+
# Bad: The file had incorrect prior content. Sync to the previous members and then delete it.
|
228
|
+
logger.warn "Sync change (update #{filename}) required"
|
229
|
+
sync_changes[filename] = group_expected_contents
|
230
|
+
logger.debug "Valid change (delete #{filename}) queued"
|
231
|
+
valid_changes[filename] = :delete
|
232
|
+
end
|
233
|
+
elsif file_contents == group_expected_contents
|
234
|
+
# Good: The file already had the correct prior content. Since the action was unsuccessful
|
235
|
+
# just skip this case doing nothing.
|
236
|
+
logger.warn "Skip change (delete #{filename}) due to unsuccessful action"
|
237
|
+
else
|
238
|
+
# Bad: The file had incorrect prior content. Wait for the successful run to sync the change.
|
239
|
+
logger.warn "Skip sync change (update #{filename}) due to unsuccessful action"
|
240
|
+
end
|
241
|
+
else
|
242
|
+
if successful_actions.member?(dn)
|
243
|
+
# Bad: The file didn't exist before but it should have. Create it now and then delete it.
|
244
|
+
logger.warn "Sync change (create #{filename}) required"
|
245
|
+
sync_changes[filename] = group_expected_contents
|
246
|
+
logger.debug "Valid change (delete #{filename}) queued"
|
247
|
+
valid_changes[filename] = :delete
|
248
|
+
else
|
249
|
+
# Bad: The file didn't exist before but it should have. Wait for the successful run to sync the change.
|
250
|
+
logger.warn "Skip sync change (create #{filename}) due to unsuccessful action"
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
# For readability: Handle 'add' logic.
|
256
|
+
def handle_add(sync_changes, valid_changes, action, successful_actions, filename, target_file, dn, group)
|
257
|
+
group_expected_contents = group_contents_as_text(group)
|
258
|
+
|
259
|
+
if File.file?(target_file)
|
260
|
+
if successful_actions.member?(dn)
|
261
|
+
# Weird case: The file was not supposed to be there but for some reason it is.
|
262
|
+
# Do a sync commit to remove the file (then a valid commit to add it back).
|
263
|
+
logger.warn "Sync change (delete #{filename}) required"
|
264
|
+
sync_changes[filename] = :delete
|
265
|
+
else
|
266
|
+
# Weird case: The file was there but the action to create the group was unsuccessful.
|
267
|
+
# Do nothing here, to let this get sync'd and updated when it completes successfully.
|
268
|
+
logger.warn "Skip sync change (delete #{filename}) due to unsuccessful action"
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
if successful_actions.member?(dn)
|
273
|
+
logger.debug "Valid change (create #{filename}) queued"
|
274
|
+
valid_changes[filename] = group_expected_contents
|
275
|
+
else
|
276
|
+
logger.warn "Skip change (add #{filename}) due to unsuccessful action"
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
# For readability: Handle 'update' logic.
|
281
|
+
def handle_update(sync_changes, valid_changes, action, successful_actions, filename, target_file, dn, group)
|
282
|
+
group_expected_contents = group_contents_as_text(group)
|
283
|
+
group_existing_contents = group_contents_as_text(action.existing)
|
284
|
+
|
285
|
+
if File.file?(target_file)
|
286
|
+
if successful_actions.member?(dn)
|
287
|
+
if File.read(target_file) == group_existing_contents
|
288
|
+
# Good: The file had the correct prior content so it can just be updated with the new content.
|
289
|
+
logger.debug "Valid change (update #{filename}) queued"
|
290
|
+
valid_changes[filename] = group_expected_contents
|
291
|
+
else
|
292
|
+
# Bad: The file had incorrect prior content. Sync to the previous members and then update it.
|
293
|
+
logger.warn "Sync change (update #{filename}) required"
|
294
|
+
sync_changes[filename] = group_existing_contents
|
295
|
+
logger.debug "Valid change (update #{filename}) queued"
|
296
|
+
valid_changes[filename] = group_expected_contents
|
297
|
+
end
|
298
|
+
elsif File.read(target_file) == group_existing_contents
|
299
|
+
# Good: The file already had the correct prior content. Since the action was unsuccessful
|
300
|
+
# just skip this case doing nothing.
|
301
|
+
logger.warn "Skip change (update #{filename}) due to unsuccessful action"
|
302
|
+
else
|
303
|
+
# Bad: The file had incorrect prior content. Wait for the successful run to sync the change.
|
304
|
+
logger.warn "Skip sync change (update #{filename}) due to unsuccessful action"
|
305
|
+
end
|
306
|
+
else
|
307
|
+
if successful_actions.member?(dn)
|
308
|
+
# Bad: The file didn't exist before but it should have. Create it now and then update it.
|
309
|
+
logger.warn "Sync change (create #{filename}) required"
|
310
|
+
sync_changes[filename] = group_existing_contents
|
311
|
+
logger.debug "Valid change (update #{filename}) queued"
|
312
|
+
valid_changes[filename] = group_expected_contents
|
313
|
+
else
|
314
|
+
# Bad: The file didn't exist before but it should have. Wait for the successful run to sync the change.
|
315
|
+
logger.warn "Skip sync change (create #{filename}) due to unsuccessful action"
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
# This defines the file format within this repository. Just dump the list of users
|
321
|
+
# and sort them, one per line.
|
322
|
+
#
|
323
|
+
# group - Entitlements::Models::Group object
|
324
|
+
#
|
325
|
+
# Returns a String with the members sorted and delimited by newlines.
|
326
|
+
Contract Entitlements::Models::Group => String
|
327
|
+
def member_strings_as_text(group)
|
328
|
+
if group.member_strings.empty?
|
329
|
+
return "# No members\n"
|
330
|
+
end
|
331
|
+
|
332
|
+
member_array = if config["person_dn_format"]
|
333
|
+
group.member_strings.map { |ms| config["person_dn_format"].gsub("%KEY%", ms).downcase }
|
334
|
+
else
|
335
|
+
group.member_strings.map(&:downcase)
|
336
|
+
end
|
337
|
+
|
338
|
+
member_array.sort.join("\n") + "\n"
|
339
|
+
end
|
340
|
+
|
341
|
+
# This defines the file format within this repository. Dump the metadata from the group, and return it as one
|
342
|
+
# metadata declaration per line ie "metadata_team_name = my_github_team"
|
343
|
+
#
|
344
|
+
# group - Entitlements::Models::Group object
|
345
|
+
#
|
346
|
+
# Returns a String with the metadata sorted and delimited by newlines.
|
347
|
+
Contract Entitlements::Models::Group => String
|
348
|
+
def metadata_strings_as_text(group)
|
349
|
+
begin
|
350
|
+
group_metadata = group.metadata
|
351
|
+
ignored_metadata = ["_filename"]
|
352
|
+
ignored_metadata.each do |metadata|
|
353
|
+
group_metadata.delete(metadata)
|
354
|
+
end
|
355
|
+
|
356
|
+
if group_metadata.empty?
|
357
|
+
return ""
|
358
|
+
end
|
359
|
+
|
360
|
+
metadata_array = group_metadata.map { |k, v| "metadata_#{k}=#{v}" }
|
361
|
+
|
362
|
+
return metadata_array.sort.join("\n") + "\n"
|
363
|
+
rescue Entitlements::Models::Group::NoMetadata
|
364
|
+
return ""
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
# This defines the file format within this repository. Grabs the full
|
369
|
+
# contents of an entitlements group - that is the members and the metadata - and returns it
|
370
|
+
#
|
371
|
+
# group - Entitlements::Models::Group object
|
372
|
+
#
|
373
|
+
# Returns a String with the content sorted and delimited by newlines.
|
374
|
+
Contract Entitlements::Models::Group => String
|
375
|
+
def group_contents_as_text(group)
|
376
|
+
group_members = member_strings_as_text(group)
|
377
|
+
group_metadata = metadata_strings_as_text(group)
|
378
|
+
group_members + group_metadata
|
379
|
+
end
|
380
|
+
|
381
|
+
# Make changes in the directory tree, then "git add" and "git commit" the changes
|
382
|
+
# with the specified commit message.
|
383
|
+
#
|
384
|
+
# changes - Hash of { filename => content } (or { filename => :delete })
|
385
|
+
# type - Either :sync or :valid
|
386
|
+
#
|
387
|
+
# Returns nothing.
|
388
|
+
Contract C::HashOf[String => C::Or[String, :delete]], C::Or[:sync, :valid], String => nil
|
389
|
+
def commit_changes(expected_changes, type, commit_message)
|
390
|
+
return if expected_changes.empty?
|
391
|
+
|
392
|
+
valid_changes = false
|
393
|
+
|
394
|
+
expected_changes.each do |filename, content|
|
395
|
+
target = File.join(checkout_directory, filename)
|
396
|
+
if content == :delete
|
397
|
+
# It's possible for two separate commits to the gitrepo to remove the same file, causing a race condition
|
398
|
+
# The first commit will delete the file from the gitrepo, and the second commit will fail with an empty commit
|
399
|
+
# For that reason, we only track a removal as a valid change if the file exists and would actually be removed
|
400
|
+
next unless File.exist?(target)
|
401
|
+
|
402
|
+
FileUtils.rm_f target
|
403
|
+
else
|
404
|
+
FileUtils.mkdir_p File.dirname(target)
|
405
|
+
File.open(target, "w") { |f| f.write(content) }
|
406
|
+
end
|
407
|
+
valid_changes = true
|
408
|
+
@repo.add(checkout_directory, filename)
|
409
|
+
end
|
410
|
+
return unless valid_changes
|
411
|
+
|
412
|
+
if type == :sync
|
413
|
+
@repo.commit(checkout_directory, "[sync commit] #{commit_message}")
|
414
|
+
else
|
415
|
+
@repo.commit(checkout_directory, commit_message)
|
416
|
+
end
|
417
|
+
@repo.push(checkout_directory)
|
418
|
+
end
|
419
|
+
|
420
|
+
# Validate the options in 'config'. Raise an error if options are invalid.
|
421
|
+
#
|
422
|
+
# Takes no arguments.
|
423
|
+
#
|
424
|
+
# Returns nothing.
|
425
|
+
# :nocov:
|
426
|
+
Contract C::None => nil
|
427
|
+
def validate_options!
|
428
|
+
require_config_keys %w[checkout_directory commit_message git_name git_email repo sshkey]
|
429
|
+
|
430
|
+
unless config["repo"] =~ %r{\A([^/]+)/([^/]+)\z}
|
431
|
+
configuration_error "'repo' must be of the form 'organization/reponame'"
|
432
|
+
end
|
433
|
+
|
434
|
+
begin
|
435
|
+
Base64.decode64(config["sshkey"])
|
436
|
+
rescue => e
|
437
|
+
configuration_error "'sshkey' could not be base64 decoded: #{e.class} #{e.message}"
|
438
|
+
end
|
439
|
+
|
440
|
+
nil
|
441
|
+
end
|
442
|
+
# :nocov:
|
443
|
+
end
|
444
|
+
end
|
445
|
+
end
|
@@ -0,0 +1,227 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# NOTE: This currently does NOT use "rugged" because we want to use SSH to connect to GitHub,
|
4
|
+
# but cannot be assured that "rugged" is compiled with SSH support everywhere this might run.
|
5
|
+
# Hence, "open3" is used to access an assumed "git" binary.
|
6
|
+
|
7
|
+
require "fileutils"
|
8
|
+
require "open3"
|
9
|
+
require "shellwords"
|
10
|
+
require "tmpdir"
|
11
|
+
|
12
|
+
module Entitlements
|
13
|
+
class Util
|
14
|
+
class GitRepo
|
15
|
+
include ::Contracts::Core
|
16
|
+
C = ::Contracts
|
17
|
+
|
18
|
+
class CommandError < StandardError; end
|
19
|
+
|
20
|
+
# This is hard-coded to use GitHub, just because. However this is here to support
|
21
|
+
# overriding this if needed (e.g. acceptance tests).
|
22
|
+
attr_accessor :github
|
23
|
+
|
24
|
+
# Constructor.
|
25
|
+
#
|
26
|
+
# repo - Name of the repo on GitHub (e.g. github/entitlements-audit)
|
27
|
+
# sshkey - Private SSH key for a user with push access to the repo
|
28
|
+
# logger - Logger object
|
29
|
+
#
|
30
|
+
# Returns nothing.
|
31
|
+
Contract C::KeywordArgs[
|
32
|
+
repo: String,
|
33
|
+
sshkey: String,
|
34
|
+
logger: C::Maybe[C::Or[Logger, Entitlements::Auditor::Base::CustomLogger]]
|
35
|
+
] => C::Any
|
36
|
+
def initialize(repo:, sshkey:, logger: Entitlements.logger)
|
37
|
+
@logger = logger
|
38
|
+
@repo = repo
|
39
|
+
@sshkey = sshkey
|
40
|
+
@github = "git@github.com:"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Run "git add" on a file.
|
44
|
+
#
|
45
|
+
# dir - A String with the path where this is to take place.
|
46
|
+
# filename - File name, relative to dir, to be added.
|
47
|
+
#
|
48
|
+
# Returns nothing.
|
49
|
+
Contract String, String => nil
|
50
|
+
def add(dir, filename)
|
51
|
+
validate_git_repository!(dir)
|
52
|
+
git(dir, ["add", filename])
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
|
56
|
+
# Clone git repo into the specified directory.
|
57
|
+
#
|
58
|
+
# dir - A String with the path where this is to take place.
|
59
|
+
#
|
60
|
+
# Returns nothing.
|
61
|
+
Contract String => nil
|
62
|
+
def clone(dir)
|
63
|
+
if File.exist?(dir)
|
64
|
+
raise Errno::EEXIST, "Cannot clone to #{dir}: already exists"
|
65
|
+
end
|
66
|
+
|
67
|
+
logger.debug "Cloning from GitHub to #{dir}"
|
68
|
+
FileUtils.mkdir_p(dir)
|
69
|
+
git(dir, ["clone", repo_url, "."], ssh: true)
|
70
|
+
nil
|
71
|
+
end
|
72
|
+
|
73
|
+
# Commit to the repo.
|
74
|
+
#
|
75
|
+
# dir - A String with the path where this is to take place.
|
76
|
+
# commit_message - A String with the commit message.
|
77
|
+
#
|
78
|
+
# Returns nothing.
|
79
|
+
Contract String, String => nil
|
80
|
+
def commit(dir, commit_message)
|
81
|
+
validate_git_repository!(dir)
|
82
|
+
git(dir, ["commit", "-m", commit_message])
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
|
86
|
+
# Configure the name and e-mail address in the repo, so git commit knows what to use.
|
87
|
+
#
|
88
|
+
# dir - A String with the path where this is to take place.
|
89
|
+
# name - A String to pass to user.name
|
90
|
+
# email - A String to pass to user.email
|
91
|
+
#
|
92
|
+
# Returns nothing.
|
93
|
+
Contract String, String, String => nil
|
94
|
+
def configure(dir, name, email)
|
95
|
+
validate_git_repository!(dir)
|
96
|
+
logger.debug "Configuring #{dir} with name=#{name.inspect} email=#{email.inspect}"
|
97
|
+
git(dir, ["config", "user.name", name])
|
98
|
+
git(dir, ["config", "user.email", email])
|
99
|
+
nil
|
100
|
+
end
|
101
|
+
|
102
|
+
# Pull the latest from GitHub.
|
103
|
+
#
|
104
|
+
# dir - A String with the path where this is to take place.
|
105
|
+
#
|
106
|
+
# Returns nothing.
|
107
|
+
Contract String => nil
|
108
|
+
def pull(dir)
|
109
|
+
validate_git_repository!(dir)
|
110
|
+
logger.debug "Pulling from GitHub to #{dir}"
|
111
|
+
git(dir, ["reset", "--hard", "HEAD"])
|
112
|
+
git(dir, ["clean", "-f", "-d"])
|
113
|
+
git(dir, ["pull"], ssh: true)
|
114
|
+
nil
|
115
|
+
end
|
116
|
+
|
117
|
+
# Push the branch to GitHub.
|
118
|
+
#
|
119
|
+
# dir - A String with the path where this is to take place.
|
120
|
+
#
|
121
|
+
# Returns nothing.
|
122
|
+
Contract String => nil
|
123
|
+
def push(dir)
|
124
|
+
validate_git_repository!(dir)
|
125
|
+
logger.debug "Pushing to GitHub from #{dir}"
|
126
|
+
git(dir, ["push", "origin", "master"], ssh: true)
|
127
|
+
nil
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
attr_reader :repo, :sshkey, :logger
|
133
|
+
|
134
|
+
# Helper method to refer to the repository on GitHub.
|
135
|
+
#
|
136
|
+
# Takes no arguments.
|
137
|
+
#
|
138
|
+
# Returns a String with the URL on GitHub.
|
139
|
+
Contract C::None => String
|
140
|
+
def repo_url
|
141
|
+
"#{github}#{repo}.git"
|
142
|
+
end
|
143
|
+
|
144
|
+
# Helper to validate that a particular directory contains a git repository.
|
145
|
+
#
|
146
|
+
# dir - Directory where the command should be run.
|
147
|
+
#
|
148
|
+
# Returns nothing (but may raise Errno::ENOENT).
|
149
|
+
Contract String => nil
|
150
|
+
def validate_git_repository!(dir)
|
151
|
+
return if File.directory?(dir) && File.directory?(File.join(dir, ".git"))
|
152
|
+
raise Errno::ENOENT, "Cannot pull in #{dir}: does not exist or is not a git repo"
|
153
|
+
end
|
154
|
+
|
155
|
+
# Run a git command.
|
156
|
+
#
|
157
|
+
# dir - Directory where the command should be run.
|
158
|
+
# args - An Array of Strings with the command line arguments.
|
159
|
+
# options - Additional options?
|
160
|
+
#
|
161
|
+
# Returns a hash of { stdout: String, stderr: String, status: Integer }
|
162
|
+
Contract String, C::ArrayOf[String], C::Maybe[C::HashOf[Symbol => C::Any]] => C::HashOf[Symbol => C::Any]
|
163
|
+
def git(dir, args, options = {})
|
164
|
+
unless File.directory?(dir)
|
165
|
+
raise Errno::ENOENT, "Attempted to run 'git' in non-existing directory #{dir}!"
|
166
|
+
end
|
167
|
+
|
168
|
+
commandline = ["git", args].flatten.map { |str| Shellwords.escape(str) }.join(" ")
|
169
|
+
|
170
|
+
out, err, code = open3_git_execute(dir, commandline, options.fetch(:ssh, false))
|
171
|
+
if code.exitstatus != 0 && options.fetch(:raise_on_error, true)
|
172
|
+
if out && !out.empty?
|
173
|
+
out.split("\n").reject { |str| str.strip.empty? }.each { |str| logger.warn "[stdout] #{str}" }
|
174
|
+
end
|
175
|
+
if err && !err.empty?
|
176
|
+
err.split("\n").reject { |str| str.strip.empty? }.each { |str| logger.warn "[stderr] #{str}" }
|
177
|
+
end
|
178
|
+
logger.fatal "Command failed (#{code.exitstatus}): #{commandline}"
|
179
|
+
raise CommandError, "git command failed"
|
180
|
+
end
|
181
|
+
|
182
|
+
{ stdout: out, stderr: err, status: code.exitstatus }
|
183
|
+
end
|
184
|
+
|
185
|
+
# Actually execute a command using open3. This handles wrapping the SSH key and git.
|
186
|
+
#
|
187
|
+
# dir - Directory where to run the command
|
188
|
+
# commandline - Commands to execute (must be properly escapted)
|
189
|
+
# ssh - True to set up a temporary directory and do the SSH key, false to skip this.
|
190
|
+
#
|
191
|
+
# Returns STDOUT, STDERR, EXITSTATUS
|
192
|
+
Contract String, String, C::Maybe[C::Bool] => [String, String, Process::Status]
|
193
|
+
def open3_git_execute(dir, commandline, ssh = false)
|
194
|
+
logger.debug "Execute: #{commandline}"
|
195
|
+
|
196
|
+
unless ssh
|
197
|
+
return Open3.capture3(commandline, chdir: dir)
|
198
|
+
end
|
199
|
+
|
200
|
+
begin
|
201
|
+
# Replace GIT_SSH with our custom SSH wrapper that installs the key and disables anything
|
202
|
+
# else custom that might be going on in the environment. Turn off prompts for the SSH key for
|
203
|
+
# github.com being trusted or not, only use the provided key as the identity, and ignore any
|
204
|
+
# ~/.ssh/config file the user running this might have set up.
|
205
|
+
tempdir = Dir.mktmpdir
|
206
|
+
File.open(File.join(tempdir, "key"), "w") { |f| f.write(sshkey) }
|
207
|
+
File.open(File.join(tempdir, "ssh"), "w") do |f|
|
208
|
+
f.puts "#!/bin/sh"
|
209
|
+
f.puts "exec /usr/bin/ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\"
|
210
|
+
f.puts " -o IdentityFile=#{Shellwords.escape(File.join(tempdir, 'key'))} -o IdentitiesOnly=yes \\"
|
211
|
+
f.puts " -F /dev/null \\"
|
212
|
+
f.puts " \"$@\""
|
213
|
+
end
|
214
|
+
FileUtils.chmod(0400, File.join(tempdir, "key"))
|
215
|
+
FileUtils.chmod(0700, File.join(tempdir, "ssh"))
|
216
|
+
|
217
|
+
# Run the command in the directory `dir` with GIT_SSH pointed at the wrapper script built above.
|
218
|
+
# Returns STDOUT, STDERR, EXITSTATUS.
|
219
|
+
Open3.capture3({ "GIT_SSH" => File.join(tempdir, "ssh") }, commandline, chdir: dir)
|
220
|
+
ensure
|
221
|
+
# Always kill the temporary directory after running, no matter what happened.
|
222
|
+
FileUtils.remove_entry_secure(tempdir)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
metadata
ADDED
@@ -0,0 +1,317 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: entitlements-gitrepo-auditor-plugin
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- GitHub, Inc. Security Ops
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-08-15 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: concurrent-ruby
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.1.9
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.1.9
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: contracts
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.16.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.16.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: faraday
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.17.3
|
48
|
+
- - "<"
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: '0.18'
|
51
|
+
type: :runtime
|
52
|
+
prerelease: false
|
53
|
+
version_requirements: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: 0.17.3
|
58
|
+
- - "<"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '0.18'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: net-ldap
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: 0.17.0
|
68
|
+
type: :runtime
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: 0.17.0
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: octokit
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '4.18'
|
82
|
+
type: :runtime
|
83
|
+
prerelease: false
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - "~>"
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: '4.18'
|
89
|
+
- !ruby/object:Gem::Dependency
|
90
|
+
name: optimist
|
91
|
+
requirement: !ruby/object:Gem::Requirement
|
92
|
+
requirements:
|
93
|
+
- - '='
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: 3.0.0
|
96
|
+
type: :runtime
|
97
|
+
prerelease: false
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - '='
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: 3.0.0
|
103
|
+
- !ruby/object:Gem::Dependency
|
104
|
+
name: contracts-rspec
|
105
|
+
requirement: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - '='
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: 0.1.0
|
110
|
+
type: :development
|
111
|
+
prerelease: false
|
112
|
+
version_requirements: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - '='
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: 0.1.0
|
117
|
+
- !ruby/object:Gem::Dependency
|
118
|
+
name: entitlements
|
119
|
+
requirement: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - '='
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: 0.1.5.g6c8e3a79
|
124
|
+
type: :development
|
125
|
+
prerelease: false
|
126
|
+
version_requirements: !ruby/object:Gem::Requirement
|
127
|
+
requirements:
|
128
|
+
- - '='
|
129
|
+
- !ruby/object:Gem::Version
|
130
|
+
version: 0.1.5.g6c8e3a79
|
131
|
+
- !ruby/object:Gem::Dependency
|
132
|
+
name: rake
|
133
|
+
requirement: !ruby/object:Gem::Requirement
|
134
|
+
requirements:
|
135
|
+
- - '='
|
136
|
+
- !ruby/object:Gem::Version
|
137
|
+
version: 13.0.6
|
138
|
+
type: :development
|
139
|
+
prerelease: false
|
140
|
+
version_requirements: !ruby/object:Gem::Requirement
|
141
|
+
requirements:
|
142
|
+
- - '='
|
143
|
+
- !ruby/object:Gem::Version
|
144
|
+
version: 13.0.6
|
145
|
+
- !ruby/object:Gem::Dependency
|
146
|
+
name: rspec
|
147
|
+
requirement: !ruby/object:Gem::Requirement
|
148
|
+
requirements:
|
149
|
+
- - '='
|
150
|
+
- !ruby/object:Gem::Version
|
151
|
+
version: 3.8.0
|
152
|
+
type: :development
|
153
|
+
prerelease: false
|
154
|
+
version_requirements: !ruby/object:Gem::Requirement
|
155
|
+
requirements:
|
156
|
+
- - '='
|
157
|
+
- !ruby/object:Gem::Version
|
158
|
+
version: 3.8.0
|
159
|
+
- !ruby/object:Gem::Dependency
|
160
|
+
name: rspec-core
|
161
|
+
requirement: !ruby/object:Gem::Requirement
|
162
|
+
requirements:
|
163
|
+
- - '='
|
164
|
+
- !ruby/object:Gem::Version
|
165
|
+
version: 3.8.0
|
166
|
+
type: :development
|
167
|
+
prerelease: false
|
168
|
+
version_requirements: !ruby/object:Gem::Requirement
|
169
|
+
requirements:
|
170
|
+
- - '='
|
171
|
+
- !ruby/object:Gem::Version
|
172
|
+
version: 3.8.0
|
173
|
+
- !ruby/object:Gem::Dependency
|
174
|
+
name: rubocop
|
175
|
+
requirement: !ruby/object:Gem::Requirement
|
176
|
+
requirements:
|
177
|
+
- - '='
|
178
|
+
- !ruby/object:Gem::Version
|
179
|
+
version: 1.29.1
|
180
|
+
type: :development
|
181
|
+
prerelease: false
|
182
|
+
version_requirements: !ruby/object:Gem::Requirement
|
183
|
+
requirements:
|
184
|
+
- - '='
|
185
|
+
- !ruby/object:Gem::Version
|
186
|
+
version: 1.29.1
|
187
|
+
- !ruby/object:Gem::Dependency
|
188
|
+
name: rubocop-github
|
189
|
+
requirement: !ruby/object:Gem::Requirement
|
190
|
+
requirements:
|
191
|
+
- - '='
|
192
|
+
- !ruby/object:Gem::Version
|
193
|
+
version: 0.17.0
|
194
|
+
type: :development
|
195
|
+
prerelease: false
|
196
|
+
version_requirements: !ruby/object:Gem::Requirement
|
197
|
+
requirements:
|
198
|
+
- - '='
|
199
|
+
- !ruby/object:Gem::Version
|
200
|
+
version: 0.17.0
|
201
|
+
- !ruby/object:Gem::Dependency
|
202
|
+
name: rubocop-performance
|
203
|
+
requirement: !ruby/object:Gem::Requirement
|
204
|
+
requirements:
|
205
|
+
- - '='
|
206
|
+
- !ruby/object:Gem::Version
|
207
|
+
version: 1.13.3
|
208
|
+
type: :development
|
209
|
+
prerelease: false
|
210
|
+
version_requirements: !ruby/object:Gem::Requirement
|
211
|
+
requirements:
|
212
|
+
- - '='
|
213
|
+
- !ruby/object:Gem::Version
|
214
|
+
version: 1.13.3
|
215
|
+
- !ruby/object:Gem::Dependency
|
216
|
+
name: rugged
|
217
|
+
requirement: !ruby/object:Gem::Requirement
|
218
|
+
requirements:
|
219
|
+
- - '='
|
220
|
+
- !ruby/object:Gem::Version
|
221
|
+
version: 0.27.5
|
222
|
+
type: :development
|
223
|
+
prerelease: false
|
224
|
+
version_requirements: !ruby/object:Gem::Requirement
|
225
|
+
requirements:
|
226
|
+
- - '='
|
227
|
+
- !ruby/object:Gem::Version
|
228
|
+
version: 0.27.5
|
229
|
+
- !ruby/object:Gem::Dependency
|
230
|
+
name: simplecov
|
231
|
+
requirement: !ruby/object:Gem::Requirement
|
232
|
+
requirements:
|
233
|
+
- - '='
|
234
|
+
- !ruby/object:Gem::Version
|
235
|
+
version: 0.16.1
|
236
|
+
type: :development
|
237
|
+
prerelease: false
|
238
|
+
version_requirements: !ruby/object:Gem::Requirement
|
239
|
+
requirements:
|
240
|
+
- - '='
|
241
|
+
- !ruby/object:Gem::Version
|
242
|
+
version: 0.16.1
|
243
|
+
- !ruby/object:Gem::Dependency
|
244
|
+
name: simplecov-erb
|
245
|
+
requirement: !ruby/object:Gem::Requirement
|
246
|
+
requirements:
|
247
|
+
- - '='
|
248
|
+
- !ruby/object:Gem::Version
|
249
|
+
version: 0.1.1
|
250
|
+
type: :development
|
251
|
+
prerelease: false
|
252
|
+
version_requirements: !ruby/object:Gem::Requirement
|
253
|
+
requirements:
|
254
|
+
- - '='
|
255
|
+
- !ruby/object:Gem::Version
|
256
|
+
version: 0.1.1
|
257
|
+
- !ruby/object:Gem::Dependency
|
258
|
+
name: vcr
|
259
|
+
requirement: !ruby/object:Gem::Requirement
|
260
|
+
requirements:
|
261
|
+
- - '='
|
262
|
+
- !ruby/object:Gem::Version
|
263
|
+
version: 4.0.0
|
264
|
+
type: :development
|
265
|
+
prerelease: false
|
266
|
+
version_requirements: !ruby/object:Gem::Requirement
|
267
|
+
requirements:
|
268
|
+
- - '='
|
269
|
+
- !ruby/object:Gem::Version
|
270
|
+
version: 4.0.0
|
271
|
+
- !ruby/object:Gem::Dependency
|
272
|
+
name: webmock
|
273
|
+
requirement: !ruby/object:Gem::Requirement
|
274
|
+
requirements:
|
275
|
+
- - '='
|
276
|
+
- !ruby/object:Gem::Version
|
277
|
+
version: 3.4.2
|
278
|
+
type: :development
|
279
|
+
prerelease: false
|
280
|
+
version_requirements: !ruby/object:Gem::Requirement
|
281
|
+
requirements:
|
282
|
+
- - '='
|
283
|
+
- !ruby/object:Gem::Version
|
284
|
+
version: 3.4.2
|
285
|
+
description: ''
|
286
|
+
email: opensource+entitlements-app@github.com
|
287
|
+
executables: []
|
288
|
+
extensions: []
|
289
|
+
extra_rdoc_files: []
|
290
|
+
files:
|
291
|
+
- VERSION
|
292
|
+
- lib/entitlements/auditor/gitrepo.rb
|
293
|
+
- lib/entitlements/util/gitrepo.rb
|
294
|
+
homepage: https://github.com/github/entitlements-gitrepo-auditor-plugin
|
295
|
+
licenses:
|
296
|
+
- MIT
|
297
|
+
metadata: {}
|
298
|
+
post_install_message:
|
299
|
+
rdoc_options: []
|
300
|
+
require_paths:
|
301
|
+
- lib
|
302
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
303
|
+
requirements:
|
304
|
+
- - ">="
|
305
|
+
- !ruby/object:Gem::Version
|
306
|
+
version: '0'
|
307
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
308
|
+
requirements:
|
309
|
+
- - ">="
|
310
|
+
- !ruby/object:Gem::Version
|
311
|
+
version: '0'
|
312
|
+
requirements: []
|
313
|
+
rubygems_version: 3.1.6
|
314
|
+
signing_key:
|
315
|
+
specification_version: 4
|
316
|
+
summary: Entitlements GitRepo Auditor
|
317
|
+
test_files: []
|