entitlements-gitrepo-auditor-plugin 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/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: []
|