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