terracop 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f057dfe056a3c276f9f3376612f36a92e6c4b7e0bf38a9a720240d63325f60b1
4
- data.tar.gz: 10f2142bf9bd9d9bca724e4f24a94fca78b11cb796a90663a7399ffa32d5939a
3
+ metadata.gz: 5fb344f85daa3aa3f6d3c3c4f0d436c0b4c0d4211e4fbc6ff3b19f44e1ea1148
4
+ data.tar.gz: 2bbff8adc3eeeedf696296e9ce90c4cbebe2ced06c781fe703f14477f8a58f93
5
5
  SHA512:
6
- metadata.gz: cf20e57e9125a9a03a296537e2b69e09e721872a4fb4ab54cd75e638f98f1d5a4c027bb054e9a30d066722e90abb9c98009dc7d597ce011fb60b64e31fd2914a
7
- data.tar.gz: aa5804ad797a3bfa980c8ca28085540f20dfdd52e9f1389e4ea98f75b38a6c6619e0280b13481b590631a447648bc7f674576642b31d0e4e05a1a83cea1aeb8f
6
+ metadata.gz: 9b695431e3b74dd944dc60f5df9fb70ac88c9884c2a51397ef1ad59539359efabdfdd767302faf2be925d31e61cc2540558deea6e5870029287a424fc7607ac7
7
+ data.tar.gz: a5b1ef53d62e241551a38aaab925df92c3811355fd42a66075e56247d78c43f3162b356a06125b8d03a176538d1ba270cc652a4c3715f9837e0cb56b645b1e3f
data/.rubocop.yml CHANGED
@@ -12,3 +12,11 @@ Style/AsciiComments:
12
12
 
13
13
  RSpec/NestedGroups:
14
14
  Max: 7
15
+
16
+ Style/GuardClause:
17
+ # Usually good, sometimes dumb.
18
+ Enabled: false
19
+
20
+ RSpec/SubjectStub:
21
+ # Sometimes, there's no other way.
22
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -4,7 +4,26 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## 0.1.0
7
+ ## 0.2.0 (2019-12-28)
8
+
9
+ - New cops:
10
+ - Aws/BadPasswordPolicy
11
+ - Aws/EnsurePropagatedTags
12
+ - Aws/FaultIntolerant
13
+ - Aws/IamInlinePolicy
14
+ - Aws/IamPolicyAttachment
15
+ - Aws/PreferLaunchTemplates
16
+ - Deprecated cop: IamRolePolicy.
17
+ - Improve test coverage.
18
+ - Improve Aws/EnsureTags to handle weird resources.
19
+ - Ignore deletions when analyzing plans.
20
+
21
+ ## 0.1.1 (2019-12-22)
22
+
23
+ - Exit with failure when offenses are found.
24
+ - Add `terracop` executable.
25
+
26
+ ## 0.1.0 (2019-12-22)
8
27
 
9
28
  - Initial release.
10
29
  - Can load Terraform 0.12 state and plan files.
data/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Terracop
2
2
 
3
- Terracop is a HashiCorp [Terraform](https://www.terraform.io/) state / plan
4
- parser and analyzer. Put it in a CI pipeline to analyze your Terraform plans
5
- or run it on already applied states and see what could be improved.
3
+ Terracop is an opinionated HashiCorp [Terraform](https://www.terraform.io/)
4
+ state / plan parser and analyzer. Put it in a CI pipeline to analyze your
5
+ Terraform plans or run it on already applied states and see what could be
6
+ improved.
6
7
 
7
8
  The checks run by Terracop go anywhere from resource names guidelines to
8
9
  identifying security holes in your configuration.
@@ -33,8 +34,8 @@ like this:
33
34
 
34
35
  $ terracop --state path/to/state/file
35
36
 
36
- Terracop can (will) parse also terraform plan files, in order to report
37
- potential issues before you apply the plan and make the problem permanent. Eg:
37
+ Terracop can also parse terraform plan files, in order to report potential
38
+ issues before you apply the plan and persist the problem. Eg:
38
39
 
39
40
  $ terraform plan -out tfplan
40
41
  $ terracop --plan tfplan
data/lib/terracop.rb CHANGED
@@ -3,12 +3,17 @@
3
3
  require 'json'
4
4
  require 'yaml'
5
5
 
6
+ require 'terracop/cop/aws/bad_password_policy'
6
7
  require 'terracop/cop/aws/describe_security_group_rules'
8
+ require 'terracop/cop/aws/ensure_propagated_tags'
7
9
  require 'terracop/cop/aws/ensure_tags'
8
- require 'terracop/cop/aws/iam_role_policy'
10
+ require 'terracop/cop/aws/fault_intolerant'
11
+ require 'terracop/cop/aws/iam_inline_policy'
12
+ require 'terracop/cop/aws/iam_policy_attachment'
9
13
  require 'terracop/cop/aws/open_egress'
10
14
  require 'terracop/cop/aws/open_ingress'
11
15
  require 'terracop/cop/aws/open_ssh'
16
+ require 'terracop/cop/aws/prefer_launch_templates'
12
17
  require 'terracop/cop/aws/unrestricted_egress_ports'
13
18
  require 'terracop/cop/aws/unrestricted_ingress_ports'
14
19
  require 'terracop/cop/aws/wide_egress'
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terracop/cop/base'
4
+
5
+ module Terracop
6
+ module Cop
7
+ module Aws
8
+ # This cop warns against a password policy that goes against industry
9
+ # best practices. Ideally the password policy should be strict enough
10
+ # to require the use of a password manager, and never expire passwords.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # resource "aws_iam_account_password_policy" "policy" {
15
+ # minimum_password_length = 4
16
+ # require_lowercase_characters = true
17
+ # require_numbers = true
18
+ # allow_users_to_change_password = false
19
+ # max_password_age = 7
20
+ # }
21
+ #
22
+ # # good
23
+ # resource "aws_iam_account_password_policy" "policy" {
24
+ # minimum_password_length = 20
25
+ # require_lowercase_characters = true
26
+ # require_uppercase_characters = true
27
+ # require_numbers = true
28
+ # require_symbols = true
29
+ # allow_users_to_change_password = true
30
+ # }
31
+ class BadPasswordPolicy < Base
32
+ register
33
+ applies_to :aws_iam_account_password_policy
34
+
35
+ def check
36
+ check_length
37
+ check_characters
38
+ check_age
39
+ end
40
+
41
+ def check_length
42
+ length = attributes['minimum_password_length']
43
+ if length && length < 14
44
+ offense('Set the minimum password length policy to at least 14.')
45
+ end
46
+ end
47
+
48
+ def check_characters
49
+ if !attributes['require_uppercase_characters'] ||
50
+ !attributes['require_lowercase_characters']
51
+ offense('Require both lowercase and uppercase characters.')
52
+ end
53
+
54
+ unless attributes['require_numbers']
55
+ offense('Require numbers in passwords.')
56
+ end
57
+
58
+ unless attributes['require_symbols']
59
+ offense('Require symbols in passwords.')
60
+ end
61
+ end
62
+
63
+ def check_age
64
+ age = attributes['max_password_age']
65
+ if age && age < 90
66
+ offense('Expiring passwords is discouraged. If you really have ' \
67
+ 'to, do not do it more than once every 3 months.')
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terracop/cop/base'
4
+
5
+ module Terracop
6
+ module Cop
7
+ module Aws
8
+ # This cop makes sure that EC2 instances launched by Autoscaling Groups
9
+ # also have tags. It is configured like the Aws/EnsureTags cop.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # resource "aws_asg" "asg" {
14
+ # tag {
15
+ # key = "Name"
16
+ # value = "asg"
17
+ # propagate_at_launch = false
18
+ # }
19
+ # }
20
+ #
21
+ # # good
22
+ # resource "aws_asg" "asg" {
23
+ # tag {
24
+ # key = "Name"
25
+ # value = "asg"
26
+ # propagate_at_launch = true
27
+ # }
28
+ # }
29
+ class EnsurePropagatedTags < Base
30
+ register
31
+ applies_to :aws_autoscaling_group
32
+
33
+ def check
34
+ if self.class.config['Required']
35
+ check_required(propagated_tags, self.class.config['Required'])
36
+ elsif propagated_tags.empty?
37
+ offense 'The EC2 instances launched by this Autoscaling group ' \
38
+ 'will not have any tags.'
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def propagated_tags
45
+ tags = attributes['tags'] || attributes['tag']
46
+ tags.select { |t| t['propagate_at_launch'] }
47
+ end
48
+
49
+ def check_required(tags, required_tags)
50
+ required_tags.each do |key|
51
+ unless tags.find { |t| t['key'] == key }
52
+ offense "Required tag \"#{key}\" is not propagated to EC2 " \
53
+ 'instances launched by this Autoscaling Group.'
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -27,24 +27,42 @@ module Terracop
27
27
  register
28
28
 
29
29
  def check
30
- return unless attributes['tags']
30
+ tags = tags_for(attributes)
31
+ return unless tags
31
32
 
32
33
  if self.class.config['Required']
33
- check_required(self.class.config['Required'])
34
- elsif attributes['tags'].empty?
34
+ check_required(tags, self.class.config['Required'])
35
+ elsif tags.empty?
35
36
  offense 'Tag resources properly.'
36
37
  end
37
38
  end
38
39
 
39
40
  private
40
41
 
41
- def check_required(required_tags)
42
+ def check_required(tags, required_tags)
42
43
  required_tags.each do |key|
43
- unless attributes['tags'][key]
44
+ unless tag(tags, key)
44
45
  offense "Required tag \"#{key}\" is missing on this resource."
45
46
  end
46
47
  end
47
48
  end
49
+
50
+ def tags_for(attributes)
51
+ case type
52
+ when 'aws_autoscaling_group'
53
+ attributes['tags'] || attributes['tag']
54
+ else
55
+ attributes['tags']
56
+ end
57
+ end
58
+
59
+ def tag(list, name)
60
+ if list.is_a?(Hash)
61
+ list[name]
62
+ else
63
+ list.find { |tag| tag['key'] == name }
64
+ end
65
+ end
48
66
  end
49
67
  end
50
68
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terracop/cop/base'
4
+
5
+ module Terracop
6
+ module Cop
7
+ module Aws
8
+ # This cop checks for Autoscaling Groups that can only launch instances
9
+ # in a specific Availability Zone. This creates an availability risk,
10
+ # as if that AZ is lost, the ASG will not be able to launch instances
11
+ # anywhere else.
12
+ #
13
+ # @example
14
+ # # bad
15
+ # resource "aws_autoscaling_group" "asg" {
16
+ # vpc_zone_identifier = ["subnet-123"]
17
+ # }
18
+ #
19
+ # # good
20
+ # resource "aws_autoscaling_group" "asg" {
21
+ # # Note that to pass this cop, the two subnets must live in
22
+ # # different AZs.
23
+ # vpc_zone_identifier = ["subnet-123", "subnet-456"]
24
+ # }
25
+ class FaultIntolerant < Base
26
+ register
27
+ applies_to :aws_autoscaling_group
28
+
29
+ MSG = 'This Autoscaling Group can launch instances in only one AZ ' \
30
+ '(%<az>s). This setup would not tolerate the loss of that AZ.'
31
+
32
+ def check
33
+ return unless attributes['availability_zones'].count < 2
34
+
35
+ offense(format(MSG, az: attributes['availability_zones'][0]))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -5,7 +5,7 @@ require 'terracop/cop/base'
5
5
  module Terracop
6
6
  module Cop
7
7
  module Aws
8
- # This cop warns against the use of inline role policies.
8
+ # This cop warns against the use of inline group/role/user policies.
9
9
  # Inline policies tend to be copy/pasted, sometimes with minor changes
10
10
  # and are not shown in the "Policies" tab of AWS IAM.
11
11
  #
@@ -33,13 +33,15 @@ module Terracop
33
33
  # role = aws_iam_role.role.name
34
34
  # policy_arn = aws_iam_policy.policy.arn
35
35
  # }
36
- class IamRolePolicy < Base
36
+ class IamInlinePolicy < Base
37
37
  register
38
- applies_to :aws_iam_role_policy
38
+ applies_to :aws_iam_group_policy, :aws_iam_role_policy,
39
+ :aws_iam_user_policy
39
40
 
40
41
  def check
41
- offense('Use aws_iam_role_policy_attachment instead of attaching ' \
42
- 'inline policies with aws_iam_role_policy.')
42
+ entity = type.scan(/aws_iam_(.+)_policy/).first.first
43
+ offense("Use aws_iam_#{entity}_policy_attachment instead of " \
44
+ "attaching inline policies with aws_iam_#{entity}_policy.")
43
45
  end
44
46
  end
45
47
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terracop/cop/base'
4
+
5
+ module Terracop
6
+ module Cop
7
+ module Aws
8
+ # This cop warns against the use of an evil all encompassing
9
+ # aws_iam_policy_attachment.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # resource "aws_iam_policy_attachment" "attach" {
14
+ # name = "test-attachment"
15
+ # policy_arn = aws_iam_policy.policy.arn
16
+ # users = [aws_iam_user.user.name]
17
+ # roles = [aws_iam_role.role.name]
18
+ # groups = [aws_iam_group.group.name]
19
+ # }
20
+ #
21
+ # # good
22
+ # resource "aws_iam_role_policy_attachment" "attach" {
23
+ # role = aws_iam_role.role.name
24
+ # policy_arn = aws_iam_policy.policy.arn
25
+ # }
26
+ #
27
+ # resource "aws_iam_user_policy_attachment" "attach" {
28
+ # user = aws_iam_user.user.name
29
+ # policy_arn = aws_iam_policy.policy.arn
30
+ # }
31
+ #
32
+ # resource "aws_iam_group_policy_attachment" "attach" {
33
+ # group = aws_iam_group.user.name
34
+ # policy_arn = aws_iam_policy.policy.arn
35
+ # }
36
+ class IamPolicyAttachment < Base
37
+ register
38
+ applies_to :iam_policy_attachment
39
+
40
+ def check
41
+ offense('Use aws_iam_role_policy_attachment, ' \
42
+ 'aws_iam_user_policy_attachment, or ' \
43
+ 'aws_iam_group_policy_attachment instead.')
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terracop/cop/aws/security_group_rule_cop'
4
+
5
+ module Terracop
6
+ module Cop
7
+ module Aws
8
+ # This cop warns against an ingress rule from 0.0.0.0/0 on port 22 (SSH).
9
+ # That is a Very Bad Idea™.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # resource "aws_launch_configuration" "lc" {}
14
+ #
15
+ # resource "aws_autoscaling_group" "asg" {
16
+ # launch_configuration = aws_launch_configuration.lc.name
17
+ # }
18
+ #
19
+ # # good
20
+ # resource "aws_launch_template" "tpl" {}
21
+ #
22
+ # resource "aws_autoscaling_group" "asg" {
23
+ # launch_template {
24
+ # id = aws_launch_template.tpl.id
25
+ # version = "$Latest"
26
+ # }
27
+ # }
28
+ class PreferLaunchTemplates < SecurityGroupRuleCop
29
+ register
30
+ applies_to :aws_launch_configuration, :aws_autoscaling_group
31
+
32
+ def check
33
+ if type == 'aws_launch_configuration' ||
34
+ attributes['launch_configuration']
35
+ offense('Prefer Launch Templates to Launch Configurations.')
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -9,7 +9,8 @@ module Terracop
9
9
  plan = decode(file)
10
10
 
11
11
  changed_resources = plan['resource_changes'].reject! do |resource|
12
- resource['change']['actions'] == ['no-op']
12
+ resource['change']['actions'] == ['no-op'] ||
13
+ resource['change']['actions'] == ['delete']
13
14
  end
14
15
 
15
16
  restruct_resources(changed_resources)
@@ -17,12 +18,14 @@ module Terracop
17
18
 
18
19
  private
19
20
 
21
+ # :nocov:
20
22
  def decode(file)
21
23
  JSON.parse(`terraform show -json #{file}`)
22
24
  rescue JSON::ParserError
23
25
  puts 'Terraform failed to decode the plan file.'
24
26
  exit
25
27
  end
28
+ # :nocov:
26
29
 
27
30
  def restruct_resources(resources)
28
31
  resources.map do |resource|
@@ -3,50 +3,59 @@
3
3
  module Terracop
4
4
  # Executes Terracop on a given state file.
5
5
  class Runner
6
- attr_accessor :state
7
-
8
6
  def initialize(type, file, formatter)
9
7
  @formatter = Formatters.const_get(formatter.to_s.capitalize).new
10
8
 
11
- if file
12
- load_state_from_file(type, file)
13
- else
14
- load_state_from_terraform
15
- end
9
+ @type = type
10
+ @file = file
16
11
  end
17
12
 
18
13
  def run
19
- offenses = @state.map do |instance|
14
+ offenses = state.map do |instance|
20
15
  Terracop::Cop.run_for(
21
16
  instance[:type], instance[:name],
22
17
  instance[:index], instance[:attributes]
23
18
  )
24
19
  end
25
20
 
26
- by_res = offenses.flatten.group_by { |o| "#{o[:type]}.#{o[:name]}" }
27
- print @formatter.generate(by_res)
21
+ print formatted(offenses)
28
22
 
29
23
  offenses.flatten.count
30
24
  end
31
25
 
26
+ def state
27
+ @state ||= begin
28
+ if @file
29
+ load_state_from_file(@type, @file)
30
+ else
31
+ load_state_from_terraform
32
+ end
33
+ end
34
+ end
35
+
32
36
  private
33
37
 
34
38
  def load_state_from_file(type, file)
35
- @state = if type == :plan
36
- PlanLoader.load(file)
37
- else
38
- StateLoader.load(file)
39
- end
39
+ if type == :plan
40
+ PlanLoader.load(file)
41
+ else
42
+ StateLoader.load(file)
43
+ end
40
44
  end
41
45
 
42
46
  # :nocov:
43
47
  def load_state_from_terraform
44
- @state = StateLoader.load_from_text(`terraform state pull`)
48
+ StateLoader.load_from_text(`terraform state pull`)
45
49
  rescue JSON::ParserError
46
50
  puts 'Run terracop somewhere with a state file or pass it directly ' \
47
51
  'with --state FILE'
48
52
  exit
49
53
  end
50
54
  # :nocov:
55
+
56
+ def formatted(offenses)
57
+ by_res = offenses.flatten.group_by { |o| "#{o[:type]}.#{o[:name]}" }
58
+ @formatter.generate(by_res)
59
+ end
51
60
  end
52
61
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Terracop
4
- VERSION = '0.1.1'
4
+ VERSION = '0.2.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: terracop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Francesco Boffa
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-12-22 00:00:00.000000000 Z
11
+ date: 2019-12-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -148,12 +148,17 @@ files:
148
148
  - default_config.yml
149
149
  - exe/terracop
150
150
  - lib/terracop.rb
151
+ - lib/terracop/cop/aws/bad_password_policy.rb
151
152
  - lib/terracop/cop/aws/describe_security_group_rules.rb
153
+ - lib/terracop/cop/aws/ensure_propagated_tags.rb
152
154
  - lib/terracop/cop/aws/ensure_tags.rb
153
- - lib/terracop/cop/aws/iam_role_policy.rb
155
+ - lib/terracop/cop/aws/fault_intolerant.rb
156
+ - lib/terracop/cop/aws/iam_inline_policy.rb
157
+ - lib/terracop/cop/aws/iam_policy_attachment.rb
154
158
  - lib/terracop/cop/aws/open_egress.rb
155
159
  - lib/terracop/cop/aws/open_ingress.rb
156
160
  - lib/terracop/cop/aws/open_ssh.rb
161
+ - lib/terracop/cop/aws/prefer_launch_templates.rb
157
162
  - lib/terracop/cop/aws/security_group_rule_cop.rb
158
163
  - lib/terracop/cop/aws/unrestricted_egress_ports.rb
159
164
  - lib/terracop/cop/aws/unrestricted_ingress_ports.rb
@@ -194,7 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
194
199
  - !ruby/object:Gem::Version
195
200
  version: '0'
196
201
  requirements: []
197
- rubygems_version: 3.0.6
202
+ rubygems_version: 3.1.2
198
203
  signing_key:
199
204
  specification_version: 4
200
205
  summary: Automatic Terraform state/plan checking tool