s3-secure 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/.rspec +3 -0
  4. data/CHANGELOG.md +7 -0
  5. data/Gemfile +6 -0
  6. data/Gemfile.lock +89 -0
  7. data/Guardfile +19 -0
  8. data/LICENSE.txt +22 -0
  9. data/README.md +56 -0
  10. data/Rakefile +14 -0
  11. data/exe/s3-secure +14 -0
  12. data/lib/s3-secure.rb +1 -0
  13. data/lib/s3_secure.rb +14 -0
  14. data/lib/s3_secure/abstract_base.rb +17 -0
  15. data/lib/s3_secure/autoloader.rb +27 -0
  16. data/lib/s3_secure/aws_services.rb +36 -0
  17. data/lib/s3_secure/batch.rb +25 -0
  18. data/lib/s3_secure/cli.rb +37 -0
  19. data/lib/s3_secure/command.rb +82 -0
  20. data/lib/s3_secure/completer.rb +159 -0
  21. data/lib/s3_secure/completer/script.rb +6 -0
  22. data/lib/s3_secure/completer/script.sh +10 -0
  23. data/lib/s3_secure/encryption.rb +27 -0
  24. data/lib/s3_secure/encryption/base.rb +4 -0
  25. data/lib/s3_secure/encryption/disable.rb +18 -0
  26. data/lib/s3_secure/encryption/enable.rb +42 -0
  27. data/lib/s3_secure/encryption/list.rb +28 -0
  28. data/lib/s3_secure/encryption/show.rb +18 -0
  29. data/lib/s3_secure/help.rb +9 -0
  30. data/lib/s3_secure/help/completion.md +20 -0
  31. data/lib/s3_secure/help/completion_script.md +3 -0
  32. data/lib/s3_secure/help/hello.md +5 -0
  33. data/lib/s3_secure/policy.rb +27 -0
  34. data/lib/s3_secure/policy/base.rb +4 -0
  35. data/lib/s3_secure/policy/checker.rb +15 -0
  36. data/lib/s3_secure/policy/document.rb +27 -0
  37. data/lib/s3_secure/policy/document/base.rb +15 -0
  38. data/lib/s3_secure/policy/document/force_ssl_only_access.rb +33 -0
  39. data/lib/s3_secure/policy/document/force_ssl_only_access_remove.rb +33 -0
  40. data/lib/s3_secure/policy/enforce.rb +36 -0
  41. data/lib/s3_secure/policy/list.rb +29 -0
  42. data/lib/s3_secure/policy/show.rb +19 -0
  43. data/lib/s3_secure/policy/unforce.rb +41 -0
  44. data/lib/s3_secure/version.rb +3 -0
  45. data/s3-secure.gemspec +33 -0
  46. data/spec/lib/cli_spec.rb +12 -0
  47. data/spec/lib/policy/checker_spec.rb +68 -0
  48. data/spec/lib/policy/document/force_ssl_remove_spec.rb +107 -0
  49. data/spec/lib/policy/document_spec.rb +68 -0
  50. data/spec/spec_helper.rb +29 -0
  51. metadata +252 -0
@@ -0,0 +1,15 @@
1
+ class S3Secure::Policy::Document
2
+ class Base
3
+ extend Memoist
4
+
5
+ def initialize(bucket, bucket_policy)
6
+ # @bucket_policy is existing document policy
7
+ @bucket, @bucket_policy = bucket, bucket_policy
8
+ end
9
+
10
+ def checker
11
+ S3Secure::Policy::Checker.new(@bucket_policy)
12
+ end
13
+ memoize :checker
14
+ end
15
+ end
@@ -0,0 +1,33 @@
1
+ class S3Secure::Policy::Document
2
+ class ForceSSLOnlyAccess < Base
3
+ def policy_document
4
+ if @bucket_policy.blank?
5
+ full_policy_document
6
+ else
7
+ updated_policy_document
8
+ end
9
+ end
10
+
11
+ def updated_policy_document
12
+ policy = JSON.load(@bucket_policy)
13
+ policy["Statement"] << ssl_enforce_statement unless checker.has?("ForceSSLOnlyAccess")
14
+ policy
15
+ end
16
+
17
+ def full_policy_document
18
+ {"Version"=>"2012-10-17",
19
+ "Statement"=>[ssl_enforce_statement]}
20
+ end
21
+
22
+ def ssl_enforce_statement
23
+ {
24
+ "Sid"=>"ForceSSLOnlyAccess",
25
+ "Effect"=>"Deny",
26
+ "Principal"=>"*",
27
+ "Action"=>"s3:GetObject",
28
+ "Resource"=>"arn:aws:s3:::#{@bucket}/*",
29
+ "Condition"=>{"Bool"=>{"aws:SecureTransport"=>"false"}}
30
+ }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ class S3Secure::Policy::Document
2
+ class ForceSSLOnlyAccessRemove < Base
3
+ def initialize(bucket, bucket_policy)
4
+ # @bucket_policy is existing document policy
5
+ @bucket, @bucket_policy = bucket, bucket_policy
6
+ end
7
+
8
+ def policy_document
9
+ return nil if @bucket_policy.blank?
10
+
11
+ updated_policy_document
12
+ end
13
+
14
+ def updated_policy_document
15
+ policy = JSON.load(@bucket_policy)
16
+
17
+ statements = policy["Statement"]
18
+ has_force_ssl = !!statements.detect { |s| s["Sid"] == "ForceSSLOnlyAccess" }
19
+ unless has_force_ssl
20
+ raise "Bucket policy does not have ForceSSLOnlyAccess"
21
+ end
22
+
23
+ if statements.size == 1
24
+ return nil # to signal for the entire bucket policy to be deleted
25
+ else
26
+ statements.delete_if { |s| s["Sid"] == "ForceSSLOnlyAccess" }
27
+ policy["Statement"] = statements
28
+ end
29
+
30
+ policy
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ class S3Secure::Policy
2
+ class Enforce < Base
3
+ def initialize(options={})
4
+ super
5
+ @sid = options[:sid]
6
+ end
7
+
8
+ def run
9
+ @s3 = s3_regional_client(@bucket)
10
+
11
+ list = S3Secure::Policy::List.new(@options)
12
+ list.set_s3(@s3)
13
+
14
+ bucket_policy = list.get_policy(@bucket)
15
+ document = Document.new(@bucket, bucket_policy)
16
+ if document.has?(@sid)
17
+ puts "Bucket policy for #{@bucket} has ForceSSLOnlyAccess policy statement already:"
18
+ puts bucket_policy
19
+ else
20
+ # Set encryption rules
21
+ # Ruby docs: https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#put_bucket_policy-instance_method
22
+ # API docs: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ServerSideEncryptionByDefault.html
23
+ #
24
+ # put_bucket_policy returns #<struct Aws::EmptyStructure>
25
+ #
26
+ policy_document = document.policy_document(@sid)
27
+ @s3.put_bucket_policy(
28
+ bucket: @bucket,
29
+ policy: policy_document,
30
+ )
31
+ puts "Add bucket policy to bucket #{@bucket}:"
32
+ puts policy_document
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ class S3Secure::Policy
2
+ class List < Base
3
+ def run
4
+ buckets.each do |bucket|
5
+ @s3 = s3_regional_client(bucket)
6
+ puts "Policy for bucket #{bucket.color(:green)}"
7
+ policy = get_policy(bucket)
8
+
9
+ if policy
10
+ puts policy
11
+ else
12
+ puts "Bucket does not have a bucket policy"
13
+ end
14
+ end
15
+ end
16
+
17
+ def get_policy(bucket)
18
+ resp = @s3.get_bucket_policy(bucket: bucket)
19
+ data = JSON.load(resp.policy.read) # String
20
+ JSON.pretty_generate(data)
21
+ rescue Aws::S3::Errors::NoSuchBucketPolicy
22
+ end
23
+
24
+ # Useful when calling List outside of the list CLI
25
+ def set_s3(client)
26
+ @s3 = client
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ class S3Secure::Policy
2
+ class Show < Base
3
+ def run
4
+ @s3 = s3_regional_client(@bucket)
5
+
6
+ list = S3Secure::Policy::List.new(@options)
7
+ list.set_s3(@s3)
8
+
9
+ policy = list.get_policy(@bucket)
10
+ if policy
11
+ puts "Bucket #{@bucket} is configured with this policy:"
12
+ puts policy
13
+ # puts policy.map(&:to_h)
14
+ else
15
+ puts "Bucket #{@bucket} is not configured bucket policy"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ class S3Secure::Policy
2
+ class Unforce < Base
3
+ def initialize(options={})
4
+ super
5
+ @sid = options[:sid]
6
+ end
7
+
8
+ def run
9
+ @s3 = s3_regional_client(@bucket)
10
+
11
+ list = S3Secure::Policy::List.new(@options)
12
+ list.set_s3(@s3)
13
+
14
+ bucket_policy = list.get_policy(@bucket)
15
+ document = Document.new(@bucket, bucket_policy, remove: true)
16
+ if document.has?(@sid)
17
+ # Set encryption rules
18
+ # Ruby docs: https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#put_bucket_policy-instance_method
19
+ # API docs: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ServerSideEncryptionByDefault.html
20
+ #
21
+ # put_bucket_policy returns #<struct Aws::EmptyStructure>
22
+ #
23
+ policy_document = document.policy_document(@sid)
24
+
25
+ if policy_document
26
+ @s3.put_bucket_policy(
27
+ bucket: @bucket,
28
+ policy: policy_document,
29
+ )
30
+ else
31
+ @s3.delete_bucket_policy(bucket: @bucket)
32
+ end
33
+
34
+ puts "Remove bucket policy to bucket #{@bucket}:"
35
+ puts policy_document if policy_document
36
+ else
37
+ puts "Bucket policy for #{@bucket} does not have ForceSSLOnlyAccess policy statement. Nothing to be done."
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,3 @@
1
+ module S3Secure
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "s3_secure/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "s3-secure"
8
+ spec.version = S3Secure::VERSION
9
+ spec.authors = ["Tung Nguyen"]
10
+ spec.email = ["tongueroo@gmail.com"]
11
+ spec.summary = "S3 Bucket security hardening tool"
12
+ spec.homepage = "https://github.com/tongueroo/s3-secure"
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files`.split($/)
16
+ spec.bindir = "exe"
17
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activesupport"
22
+ spec.add_dependency "aws-sdk-s3"
23
+ spec.add_dependency "memoist"
24
+ spec.add_dependency "rainbow"
25
+ spec.add_dependency "thor"
26
+ spec.add_dependency "zeitwerk"
27
+
28
+ spec.add_development_dependency "bundler"
29
+ spec.add_development_dependency "byebug"
30
+ spec.add_development_dependency "cli_markdown"
31
+ spec.add_development_dependency "rake"
32
+ spec.add_development_dependency "rspec"
33
+ end
@@ -0,0 +1,12 @@
1
+ describe S3Secure::CLI do
2
+ before(:all) do
3
+ @args = "--from Tung"
4
+ end
5
+
6
+ describe "s3-secure" do
7
+ # it "hello" do
8
+ # out = execute("exe/s3-secure hello world #{@args}")
9
+ # expect(out).to include("from: Tung\nHello world")
10
+ # end
11
+ end
12
+ end
@@ -0,0 +1,68 @@
1
+ describe S3Secure::Policy::Checker do
2
+ subject { S3Secure::Policy::Checker.new(policy_json) }
3
+
4
+ describe "already has ForceSSLOnlyAccess" do
5
+ let(:policy_json) {
6
+ <<~JSON
7
+ {
8
+ "Version": "2012-10-17",
9
+ "Statement": [
10
+ {
11
+ "Sid": "ForceSSLOnlyAccess",
12
+ "Effect": "Deny",
13
+ "Principal": "*",
14
+ "Action": "s3:GetObject",
15
+ "Resource": "arn:aws:s3:::my-test-s3-secure-us-east-1/*",
16
+ "Condition": {
17
+ "Bool": {
18
+ "aws:SecureTransport": "false"
19
+ }
20
+ }
21
+ }
22
+ ]
23
+ }
24
+ JSON
25
+ }
26
+
27
+ it "has?" do
28
+ result = subject.has?("ForceSSLOnlyAccess")
29
+ expect(result).to be true
30
+ end
31
+ end
32
+
33
+ describe "doesnt have ForceSSLOnlyAccess" do
34
+ let(:policy_json) {
35
+ <<~JSON
36
+ {
37
+ "Version": "2008-10-17",
38
+ "Id": "PolicyForCloudFrontPrivateContent",
39
+ "Statement": [
40
+ {
41
+ "Sid": "1",
42
+ "Effect": "Allow",
43
+ "Principal": {
44
+ "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E27G6OAEXAMPLE"
45
+ },
46
+ "Action": "s3:GetObject",
47
+ "Resource": "arn:aws:s3:::test-fake-website/*"
48
+ }
49
+ ]
50
+ }
51
+ JSON
52
+ }
53
+
54
+ it "has?" do
55
+ result = subject.has?("ForceSSLOnlyAccess")
56
+ expect(result).to be false
57
+ end
58
+ end
59
+
60
+ describe "empty policy" do
61
+ let(:policy_json) { nil }
62
+
63
+ it "has?" do
64
+ result = subject.has?("ForceSSLOnlyAccess")
65
+ expect(result).to be false
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,107 @@
1
+ describe S3Secure::Policy::Document::ForceSSLOnlyAccessRemove do
2
+ subject { S3Secure::Policy::Document::ForceSSLOnlyAccessRemove.new("my-bucket", policy_json) }
3
+
4
+ describe "already has ForceSSLOnlyAccess only" do
5
+ let(:policy_json) {
6
+ <<~JSON
7
+ {
8
+ "Version": "2012-10-17",
9
+ "Statement": [
10
+ {
11
+ "Sid": "ForceSSLOnlyAccess",
12
+ "Effect": "Deny",
13
+ "Principal": "*",
14
+ "Action": "s3:GetObject",
15
+ "Resource": "arn:aws:s3:::my-bucket/*",
16
+ "Condition": {
17
+ "Bool": {
18
+ "aws:SecureTransport": "false"
19
+ }
20
+ }
21
+ }
22
+ ]
23
+ }
24
+ JSON
25
+ }
26
+
27
+ it "policy_document" do
28
+ result = subject.policy_document
29
+ expect(result).to be nil
30
+ end
31
+ end
32
+
33
+ describe "already has ForceSSLOnlyAccess and another statement" do
34
+ let(:policy_json) {
35
+ <<~JSON
36
+ {
37
+ "Version": "2008-10-17",
38
+ "Id": "PolicyForCloudFrontPrivateContent",
39
+ "Statement": [
40
+ {
41
+ "Sid": "1",
42
+ "Effect": "Allow",
43
+ "Principal": {
44
+ "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E27G6OAEXAMPLE"
45
+ },
46
+ "Action": "s3:GetObject",
47
+ "Resource": "arn:aws:s3:::test-fake-website/*"
48
+ },
49
+ {
50
+ "Sid": "ForceSSLOnlyAccess",
51
+ "Effect": "Deny",
52
+ "Principal": "*",
53
+ "Action": "s3:GetObject",
54
+ "Resource": "arn:aws:s3:::my-bucket/*",
55
+ "Condition": {
56
+ "Bool": {
57
+ "aws:SecureTransport": "false"
58
+ }
59
+ }
60
+ }
61
+ ]
62
+ }
63
+ JSON
64
+ }
65
+
66
+ it "policy_document" do
67
+ result = JSON.pretty_generate(subject.policy_document)
68
+ expect(result).not_to include("ForceSSLOnlyAccess")
69
+ expect(result).to include("PolicyForCloudFrontPrivateContent")
70
+ end
71
+ end
72
+
73
+ describe "empty policy" do
74
+ let(:policy_json) { nil }
75
+
76
+ it "policy_document" do
77
+ result = subject.policy_document
78
+ expect(result).to be nil
79
+ end
80
+ end
81
+
82
+ describe "doesnt have ForceSSLOnlyAccess" do
83
+ let(:policy_json) {
84
+ <<~JSON
85
+ {
86
+ "Version": "2008-10-17",
87
+ "Id": "PolicyForCloudFrontPrivateContent",
88
+ "Statement": [
89
+ {
90
+ "Sid": "1",
91
+ "Effect": "Allow",
92
+ "Principal": {
93
+ "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E27G6OAEXAMPLE"
94
+ },
95
+ "Action": "s3:GetObject",
96
+ "Resource": "arn:aws:s3:::test-fake-website/*"
97
+ }
98
+ ]
99
+ }
100
+ JSON
101
+ }
102
+
103
+ it "policy_document" do
104
+ expect { subject.policy_document }.to raise_error(RuntimeError)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,68 @@
1
+ describe S3Secure::Policy::Document do
2
+ subject { S3Secure::Policy::Document.new("my-bucket", policy_json) }
3
+
4
+ describe "already has ForceSSLOnlyAccess" do
5
+ let(:policy_json) {
6
+ <<~JSON
7
+ {
8
+ "Version": "2012-10-17",
9
+ "Statement": [
10
+ {
11
+ "Sid": "ForceSSLOnlyAccess",
12
+ "Effect": "Deny",
13
+ "Principal": "*",
14
+ "Action": "s3:GetObject",
15
+ "Resource": "arn:aws:s3:::my-bucket/*",
16
+ "Condition": {
17
+ "Bool": {
18
+ "aws:SecureTransport": "false"
19
+ }
20
+ }
21
+ }
22
+ ]
23
+ }
24
+ JSON
25
+ }
26
+
27
+ it "policy_document" do
28
+ result = subject.policy_document("ForceSSLOnlyAccess")
29
+ expect(result).to include("ForceSSLOnlyAccess")
30
+ end
31
+ end
32
+
33
+ describe "doesnt have ForceSSLOnlyAccess" do
34
+ let(:policy_json) {
35
+ <<~JSON
36
+ {
37
+ "Version": "2008-10-17",
38
+ "Id": "PolicyForCloudFrontPrivateContent",
39
+ "Statement": [
40
+ {
41
+ "Sid": "1",
42
+ "Effect": "Allow",
43
+ "Principal": {
44
+ "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E27G6OAEXAMPLE"
45
+ },
46
+ "Action": "s3:GetObject",
47
+ "Resource": "arn:aws:s3:::test-fake-website/*"
48
+ }
49
+ ]
50
+ }
51
+ JSON
52
+ }
53
+
54
+ it "policy_document" do
55
+ result = subject.policy_document("ForceSSLOnlyAccess")
56
+ expect(result).to include("ForceSSLOnlyAccess")
57
+ end
58
+ end
59
+
60
+ describe "empty policy" do
61
+ let(:policy_json) { nil }
62
+
63
+ it "policy_document" do
64
+ result = subject.policy_document("ForceSSLOnlyAccess")
65
+ expect(result).to include("ForceSSLOnlyAccess")
66
+ end
67
+ end
68
+ end