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 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: []