banacle 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,138 @@
1
+ require 'banacle/slack'
2
+ require 'banacle/slash_command/command'
3
+
4
+ module Banacle
5
+ module InteractiveMessage
6
+ class Renderer
7
+ def self.render(params, command)
8
+ new(params, command).render
9
+ end
10
+
11
+ def initialize(params, command)
12
+ @params = params
13
+ @command = command
14
+ end
15
+
16
+ attr_reader :params, :command
17
+
18
+ def render
19
+ payload = JSON.parse(params["payload"], symbolize_names: true)
20
+ action = Slack::Action.new(payload[:actions].first)
21
+
22
+ if action.approved?
23
+ render_approved_message(payload, command)
24
+ elsif action.rejected?
25
+ render_rejected_message(payload, command)
26
+ elsif action.cancelled?
27
+ render_cancelled_message(payload, command)
28
+ else
29
+ # Do nothing
30
+ end
31
+ end
32
+
33
+ protected
34
+
35
+ # override
36
+ def authenticated_user?
37
+ true
38
+ end
39
+
40
+ private
41
+
42
+ def render_approved_message(payload, command)
43
+ unless valid_approver?
44
+ return render_error("you cannot approve the request by yourself")
45
+ end
46
+
47
+ if authenticated_user?
48
+ result = command.execute
49
+
50
+ text = original_message_text
51
+ text += ":white_check_mark: *<@#{actioner_id}> approved this request*\n"
52
+ text += "Result:\n"
53
+ text += "```\n"
54
+ text += result
55
+ text += "```"
56
+
57
+ render_replacing_message(text)
58
+ else
59
+ render_error("you are not permitted to approve the request")
60
+ end
61
+ end
62
+
63
+ def render_rejected_message(payload, command)
64
+ unless valid_rejector?
65
+ return render_error("you cannot reject the request by yourself")
66
+ end
67
+
68
+ if authenticated_user?
69
+ text = original_message_text
70
+ text += ":no_entry_sign: *<@#{actioner_id}> rejected this request*"
71
+
72
+ render_replacing_message(text)
73
+ else
74
+ render_error("you are not permitted to reject the request")
75
+ end
76
+ end
77
+
78
+ def render_cancelled_message(payload, command)
79
+ unless valid_canceller?
80
+ return render_error("you cannot cancel the request by other than the requester")
81
+ end
82
+
83
+ text = original_message_text
84
+ text += "\nThe request was cancelled."
85
+
86
+ render_replacing_message(text)
87
+ end
88
+
89
+ def render_replacing_message(text)
90
+ Slack::Response.new(
91
+ response_type: "in_channel",
92
+ replace_original: true,
93
+ text: text,
94
+ ).to_json
95
+ end
96
+
97
+ def render_error(error)
98
+ Slack::Response.new(
99
+ response_type: "ephemeral",
100
+ replace_original: false,
101
+ text: "An error occurred: #{error}",
102
+ ).to_json
103
+ end
104
+
105
+ def valid_approver?
106
+ ENV['BANACLE_SKIP_VALIDATION'] || !self_actioned?
107
+ end
108
+
109
+ def valid_rejector?
110
+ ENV['BANACLE_SKIP_VALIDATION'] || !self_actioned?
111
+ end
112
+
113
+ def valid_canceller?
114
+ ENV['BANACLE_SKIP_VALIDATION'] || self_actioned?
115
+ end
116
+
117
+ def self_actioned?
118
+ requester_id == actioner_id
119
+ end
120
+
121
+ def requester_id
122
+ original_message_text.match(/\A<@([^>]+)>/)[1]
123
+ end
124
+
125
+ def actioner_id
126
+ payload[:user][:id]
127
+ end
128
+
129
+ def original_message_text
130
+ payload[:original_message][:text]
131
+ end
132
+
133
+ def payload
134
+ @payload ||= JSON.parse(params["payload"], symbolize_names: true)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,191 @@
1
+ module Banacle
2
+ module Slack
3
+ Response = Struct.new(:response_type, :replace_original, :text, :attachments, keyword_init: true) do
4
+ class ValidationError < StandardError; end
5
+
6
+ def initialize(*args)
7
+ super
8
+ self.set_default!
9
+ self.validate!
10
+ self
11
+ end
12
+
13
+ def set_default!
14
+ self.response_type ||= "in_channel"
15
+ self.replace_original = true if self.replace_original.nil?
16
+ self.text ||= ""
17
+ self.attachments ||= []
18
+ end
19
+
20
+ def validate!
21
+ unless self.replace_original.is_a?(TrueClass) || self.replace_original.is_a?(FalseClass)
22
+ raise ValidationError.new("replace_original must be TrueClass or FalseClass")
23
+ end
24
+
25
+ %i(response_type text).each do |label|
26
+ unless self.send(label).is_a?(String)
27
+ raise ValidationError.new("#{attr} must be String")
28
+ end
29
+ end
30
+
31
+ attachments.each do |a|
32
+ unless a.is_a?(Slack::Attachment)
33
+ raise ValidationError.new("One of attachments #{a.inspect} must be Slack::Attachment")
34
+ end
35
+ end
36
+ end
37
+
38
+ def as_json
39
+ self.to_h.tap { |h| h[:attachments] = h[:attachments].map(&:as_json) }
40
+ end
41
+
42
+ def to_json
43
+ as_json.to_json
44
+ end
45
+ end
46
+
47
+ Attachment = Struct.new(:text, :fallback, :callback_id, :color, \
48
+ :attachment_type, :actions, keyword_init: true) do
49
+ class ValidationError < StandardError; end
50
+
51
+ def initialize(*args)
52
+ super
53
+ self.set_default!
54
+ self.validate!
55
+ self
56
+ end
57
+
58
+ def set_default!
59
+ self.text ||= ''
60
+ self.fallback ||= ''
61
+ self.callback_id ||= ''
62
+ self.color ||= ''
63
+ self.attachment_type ||= ''
64
+ self.actions ||= []
65
+ end
66
+
67
+ def validate!
68
+ %i(text fallback callback_id color attachment_type).each do |label|
69
+ unless self.send(label).is_a?(String)
70
+ raise ValidationError.new("#{attr} must be String")
71
+ end
72
+ end
73
+
74
+ self.actions.each do |a|
75
+ unless a.is_a?(Slack::Action)
76
+ raise ValidationError.new("One of actions #{a.inspect} must be Slack::Action")
77
+ end
78
+ end
79
+ end
80
+
81
+ def as_json
82
+ self.to_h.tap { |h| h[:actions] = h[:actions].map(&:as_json) }
83
+ end
84
+ end
85
+
86
+ Action = Struct.new(:name, :text, :style, :type, :value, :confirm, keyword_init: true) do
87
+ class ValidationError < StandardError; end
88
+
89
+ def self.approve_button
90
+ self.build_button('approve', style: 'primary', confirm: Confirm.approve)
91
+ end
92
+
93
+ def self.reject_button
94
+ self.build_button('reject', style: 'danger')
95
+ end
96
+
97
+ def self.cancel_button
98
+ self.build_button('cancel')
99
+ end
100
+
101
+ def self.build_button(value, style: 'default', confirm: nil)
102
+ self.build(value, style, 'button', confirm)
103
+ end
104
+
105
+ def self.build(value, style, type, confirm)
106
+ self.new(name: value, text: value.capitalize, style: style, type: type, value: value, confirm: confirm)
107
+ end
108
+
109
+ def initialize(*args)
110
+ super
111
+ self.set_default!
112
+ self.validate!
113
+ self
114
+ end
115
+
116
+ def set_default!
117
+ self.name ||= ''
118
+ self.text ||= ''
119
+ self.style ||= ''
120
+ self.type ||= ''
121
+ self.value ||= ''
122
+ end
123
+
124
+ def validate!
125
+ %i(name text style type value).each do |label|
126
+ unless self.send(label).is_a?(String)
127
+ raise ValidationError.new("#{attr} must be String")
128
+ end
129
+ end
130
+
131
+ if self.confirm && !self.confirm.is_a?(Confirm)
132
+ raise ValidationError.new("confirm must be Slack::Confirm")
133
+ end
134
+ end
135
+
136
+ def approved?
137
+ self.value == 'approve'
138
+ end
139
+
140
+ def rejected?
141
+ self.value == 'reject'
142
+ end
143
+
144
+ def cancelled?
145
+ self.value == 'cancel'
146
+ end
147
+
148
+ def as_json
149
+ self.to_h.tap do |h|
150
+ if h[:confirm]
151
+ h[:confirm] = h[:confirm].as_json
152
+ else
153
+ h.delete(:confirm)
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ Confirm = Struct.new(:title, :text, :ok_text, :dismiss_text, keyword_init: true) do
160
+ def self.approve
161
+ self.new(text: 'The operation will be performed immediately.')
162
+ end
163
+
164
+ def initialize(*args)
165
+ super
166
+ self.set_default!
167
+ self.validate!
168
+ self
169
+ end
170
+
171
+ def set_default!
172
+ self.title ||= 'Are you sure?'
173
+ self.text ||= ''
174
+ self.ok_text ||= 'Yes'
175
+ self.dismiss_text ||= 'No'
176
+ end
177
+
178
+ def validate!
179
+ %i(title text ok_text dismiss_text).each do |label|
180
+ unless self.send(label).is_a?(String)
181
+ raise ValidationError.new("#{attr} must be String")
182
+ end
183
+ end
184
+ end
185
+
186
+ def as_json
187
+ self.to_h
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,32 @@
1
+ require 'base64'
2
+ require 'openssl'
3
+
4
+ module Banacle
5
+ class SlackValidator
6
+ SLACK_SIGNING_SECRET_VERSION = 'v0'.freeze
7
+
8
+ def self.valid_signature?(request)
9
+ new.valid_signature?(request)
10
+ end
11
+
12
+ def valid_signature?(request)
13
+ body = request.env["rack.request.form_vars"]
14
+ slack_signature = request.env["HTTP_X_SLACK_SIGNATURE"]
15
+ slack_timestamp = request.env["HTTP_X_SLACK_REQUEST_TIMESTAMP"]
16
+
17
+ # https://api.slack.com/docs/verifying-requests-from-slack#verification_token_deprecation
18
+ if (slack_timestamp.to_i - Time.now.to_i).abs > 60 * 5
19
+ return false
20
+ end
21
+
22
+ sig_basestring = "#{SLACK_SIGNING_SECRET_VERSION}:#{slack_timestamp}:#{body}"
23
+ digest = OpenSSL::HMAC.hexdigest("SHA256", signing_secret, sig_basestring)
24
+
25
+ slack_signature == "#{SLACK_SIGNING_SECRET_VERSION}=#{digest}"
26
+ end
27
+
28
+ def signing_secret
29
+ ENV.fetch('BANACLE_SLACK_SIGNING_SECRET')
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,100 @@
1
+ require 'ipaddr'
2
+
3
+ require 'banacle/aws_wrapper/vpc'
4
+ require 'banacle/slash_command/error'
5
+ require 'banacle/slash_command/command'
6
+
7
+ module Banacle
8
+ module SlashCommand
9
+ class Builder
10
+ class InvalidActionError < Error; end
11
+ class InvalidRegionError < Error; end
12
+ class InvalidVpcError < Error; end
13
+ class InvalidCidrBlockError < Error; end
14
+
15
+ def self.build(action:, region:, vpc_id_or_name:, cidr_blocks:)
16
+ new(action: action, region: region, vpc_id_or_name: vpc_id_or_name, cidr_blocks: cidr_blocks).build
17
+ end
18
+
19
+ def initialize(action:, region:, vpc_id_or_name:, cidr_blocks:)
20
+ @action = action
21
+ @region = region
22
+ @vpc_id_or_name = vpc_id_or_name
23
+ @cidr_blocks = cidr_blocks
24
+ end
25
+
26
+ attr_reader :action, :region, :vpc_id_or_name, :cidr_blocks
27
+ attr_accessor :vpc_id
28
+
29
+ def build
30
+ validate!
31
+ set_vpc_id!
32
+ normalize_cidr_blocks!
33
+
34
+ Command.new(action: action, region: region, vpc_id: vpc_id, cidr_blocks: cidr_blocks)
35
+ end
36
+
37
+ def validate!
38
+ validate_action!
39
+ validate_region!
40
+ validate_vpc_id_or_name!
41
+ validate_cidr_blocks!
42
+ end
43
+
44
+ def validate_action!
45
+ if !action || action.empty?
46
+ raise InvalidActionError.new("action is required")
47
+ end
48
+
49
+ unless Command::PERMITTED_ACTIONS.include?(action)
50
+ raise InvalidActionError.new("permitted actions are: (#{Command::PERMITTED_ACTIONS.join("|")})")
51
+ end
52
+ end
53
+
54
+ def validate_region!
55
+ if !region || region.empty?
56
+ raise InvalidRegionError.new("region is required")
57
+ end
58
+ end
59
+
60
+ def validate_vpc_id_or_name!
61
+ unless vpc_id_or_name
62
+ raise InvalidVpcError.new("vpc_id or vpc_name is required with #{action} action")
63
+ end
64
+ end
65
+
66
+ def validate_cidr_blocks!
67
+ if !cidr_blocks || cidr_blocks.empty?
68
+ raise InvalidVpcError.new("at least one cidr_block is required with #{action} action")
69
+ end
70
+
71
+ cidr_blocks.each do |cidr_block|
72
+ begin
73
+ IPAddr.new(cidr_block)
74
+ rescue IPAddr::InvalidAddressError
75
+ raise InvalidCidrBlockError.new("#{cidr_block} is invalid address")
76
+ end
77
+ end
78
+ end
79
+
80
+ def set_vpc_id!
81
+ begin
82
+ self.vpc_id = AwsWrapper::Vpc.resolve_vpc_id(region, vpc_id_or_name)
83
+ rescue AwsWrapper::Vpc::InvalidRegionError
84
+ raise InvalidRegionError.new("specified region: #{region} is invalid")
85
+ end
86
+
87
+ unless vpc_id
88
+ raise InvalidVpcError.new("specified vpc: #{vpc_id_or_name} in #{region} not found")
89
+ end
90
+ end
91
+
92
+ def normalize_cidr_blocks!
93
+ cidr_blocks.map! do |c|
94
+ ip = IPAddr.new(c)
95
+ "#{ip}/#{ip.prefix}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,75 @@
1
+ require 'banacle/aws_wrapper/nacl'
2
+ require 'banacle/aws_wrapper/vpc'
3
+
4
+ module Banacle
5
+ module SlashCommand
6
+ class Command
7
+ CREATE_ACTION = 'create'.freeze
8
+ DELETE_ACTION = 'delete'.freeze
9
+
10
+ PERMITTED_ACTIONS = [CREATE_ACTION, DELETE_ACTION].freeze
11
+
12
+ def initialize(action:, region:, vpc_id:, cidr_blocks:)
13
+ @action = action
14
+ @region = region
15
+ @vpc_id = vpc_id
16
+ @cidr_blocks = cidr_blocks
17
+ end
18
+
19
+ attr_reader :action, :region, :vpc_id, :cidr_blocks
20
+
21
+ def execute
22
+ case action
23
+ when CREATE_ACTION
24
+ create_nacl
25
+ when DELETE_ACTION
26
+ delete_nacl
27
+ else
28
+ # Do nothing
29
+ end
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ action: action,
35
+ region: region,
36
+ vpc_id: vpc_id,
37
+ cidr_blocks: cidr_blocks,
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def create_nacl
44
+ results = AwsWrapper::Nacl.create_network_acl_ingress_entries(
45
+ region: region,
46
+ vpc_id: vpc_id,
47
+ cidr_blocks: cidr_blocks,
48
+ )
49
+
50
+ format_results(results)
51
+ end
52
+
53
+ def delete_nacl
54
+ results = AwsWrapper::Nacl.delete_network_acl_entries(
55
+ region: region,
56
+ vpc_id: vpc_id,
57
+ cidr_blocks: cidr_blocks,
58
+ )
59
+
60
+ format_results(results)
61
+ end
62
+
63
+ def format_results(results)
64
+ results.map do |cidr_block, result|
65
+ t = "#{action} DENY #{cidr_block} => "
66
+ if result.status
67
+ t += "succeeded"
68
+ else
69
+ t += "error: #{result.error}"
70
+ end
71
+ end.join("\n")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,6 @@
1
+ module Banacle
2
+ module SlashCommand
3
+ class Error < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,42 @@
1
+ require 'banacle/slash_command/error'
2
+ require 'banacle/slash_command/builder'
3
+
4
+ module Banacle
5
+ module SlashCommand
6
+ class Parser
7
+ class ParseError < Error; end
8
+
9
+ def self.parse(text)
10
+ new.parse(text)
11
+ end
12
+
13
+ #
14
+ # /banacle (create|delete) [region] [vpc_id or vpc_name] [cidr_block1,cidr_block2,...]
15
+ #
16
+ def parse(text)
17
+ elems = text.split(" ")
18
+ action, region, vpc_id_or_name, cidr_blocks_str = elems
19
+
20
+ unless action
21
+ raise ParseError.new("action is required")
22
+ end
23
+
24
+ unless region
25
+ raise ParseError.new("region is required")
26
+ end
27
+
28
+ cidr_blocks = []
29
+ if cidr_blocks_str
30
+ cidr_blocks = cidr_blocks_str.split(",")
31
+ end
32
+
33
+ SlashCommand::Builder.build(
34
+ action: action,
35
+ region: region,
36
+ vpc_id_or_name: vpc_id_or_name,
37
+ cidr_blocks: cidr_blocks,
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,56 @@
1
+ require 'banacle/slack'
2
+ require 'banacle/slash_command/builder'
3
+ require 'banacle/slash_command/command'
4
+
5
+ module Banacle
6
+ module SlashCommand
7
+ class Renderer
8
+ def self.render(params, command)
9
+ new.render(params, command)
10
+ end
11
+
12
+ def self.render_error(error)
13
+ new.render_error(error)
14
+ end
15
+
16
+ def render(params, command)
17
+ render_approval_request(params, command)
18
+ end
19
+
20
+ def render_error(error)
21
+ Slack::Response.new(
22
+ response_type: "ephemeral",
23
+ text: "An error occurred: #{error}",
24
+ ).to_json
25
+ end
26
+
27
+ def render_approval_request(params, command)
28
+ text = <<-EOS
29
+ <@#{params["user_id"]}> wants to *#{command.action} NACL DENY entry* under the following conditions:
30
+ ```
31
+ #{JSON.pretty_generate(command.to_h)}
32
+ ```
33
+ EOS
34
+
35
+ Slack::Response.new(
36
+ response_type: "in_channel",
37
+ text: text,
38
+ attachments: [
39
+ Slack::Attachment.new(
40
+ text: "*Approval Request*",
41
+ fallback: "TBD",
42
+ callback_id: "banacle_approval_request",
43
+ color: "#3AA3E3",
44
+ attachment_type: "default",
45
+ actions: [
46
+ Slack::Action.approve_button,
47
+ Slack::Action.reject_button,
48
+ Slack::Action.cancel_button,
49
+ ]
50
+ ),
51
+ ],
52
+ ).to_json
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,3 @@
1
+ module Banacle
2
+ VERSION = "0.1.1"
3
+ end
data/lib/banacle.rb ADDED
@@ -0,0 +1,2 @@
1
+ require "banacle/version"
2
+ require "banacle/app"