inspec-iggy 0.2.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,56 +4,78 @@
4
4
  # Copyright:: 2018, Chef Software, Inc <legal@chef.io>
5
5
  #
6
6
 
7
- require "inspec"
7
+ require 'inspec'
8
8
 
9
- module Iggy
10
- class InspecHelper
9
+ module InspecPlugins
10
+ module Iggy
11
+ class InspecHelper
12
+ # constants for the InSpec resources
13
+ RESOURCES = Inspec::Resource.registry.keys
11
14
 
12
- # constants for the InSpec resources
13
- RESOURCES = Inspec::Resource.registry.keys
15
+ # translate Terraform resource name to InSpec
16
+ TRANSLATED_RESOURCES = {
17
+ 'aws_instance' => 'aws_ec2_instance',
18
+ # 'aws_route' => 'aws_route_table' # needs route_table_id instead of id
19
+ }.freeze
14
20
 
15
- # translate Terraform resource name to InSpec
16
- TERRAFORM_RESOURCES = {
17
- "aws_instance" => "aws_ec2_instance",
18
- # 'aws_route' => 'aws_route_table' # needs route_table_id instead of id
19
- }
21
+ # # there really should be some way to get this directly from InSpec's resources
22
+ def self.resource_properties(resource)
23
+ # remove the common methods, in theory only leaving only unique InSpec properties
24
+ inspec_properties = Inspec::Resource.registry[resource].instance_methods - COMMON_PROPERTIES
25
+ # get InSpec properties by method names
26
+ inspec_properties.collect!(&:to_s)
27
+ Inspec::Log.debug "InspecHelper.resource_properties #{resource} properties = #{inspec_properties}"
20
28
 
21
- # # there really should be some way to get this directly from InSpec's resources
22
- def self.resource_properties(resource)
23
- # remove the common methods, in theory only leaving only unique InSpec properties
24
- inspec_properties = Inspec::Resource.registry[resource].instance_methods - COMMON_PROPERTIES
25
- # get InSpec properties by method names
26
- inspec_properties.collect! { |x| x.to_s }
27
- Inspec::Log.debug "Iggy::InspecHelper.resource_properties #{resource} properties = #{inspec_properties}"
28
-
29
- inspec_properties
30
- end
29
+ inspec_properties
30
+ end
31
31
 
32
- def self.print_commands(extracted_profiles)
33
- extracted_profiles.keys.each do |cmd|
34
- type = extracted_profiles[cmd]["type"]
35
- url = extracted_profiles[cmd]["url"]
36
- key_name = extracted_profiles[cmd]["key_name"]
37
- if type == "aws_instance"
38
- ip = extracted_profiles[cmd]["public_ip"]
39
- puts "inspec exec #{url} -t ssh://#{ip} -i #{key_name}"
40
- else
41
- puts "inspec exec #{url} -t aws://us-west-2"
32
+ def self.print_commands(extracted_profiles)
33
+ extracted_profiles.keys.each do |cmd|
34
+ type = extracted_profiles[cmd]['type']
35
+ url = extracted_profiles[cmd]['url']
36
+ key_name = extracted_profiles[cmd]['key_name']
37
+ if type == 'aws_instance'
38
+ ip = extracted_profiles[cmd]['public_ip']
39
+ puts "inspec exec #{url} -t ssh://#{ip} -i #{key_name}"
40
+ else
41
+ puts "inspec exec #{url} -t aws://us-west-2"
42
+ end
42
43
  end
43
44
  end
44
- end
45
45
 
46
- def self.print_controls(file, generated_controls)
47
- puts "# encoding: utf-8\n#"
46
+ def self.tf_controls(title, generated_controls)
47
+ content = "# encoding: utf-8\n#\n\n"
48
48
 
49
- puts "\ntitle '#{File.absolute_path(file)} controls generated by Iggy v#{Iggy::VERSION}'"
49
+ content += "title \"#{title}: generated by Iggy v#{Iggy::VERSION}\"\n"
50
50
 
51
- # write all controls
52
- puts generated_controls.flatten.map(&:to_ruby).join("\n\n")
53
- end
51
+ # write all controls
52
+ content + generated_controls.flatten.map(&:to_ruby).join("\n\n")
53
+ end
54
54
 
55
- # a hack for sure, finds common methods as proxy for InSpec properties
56
- COMMON_PROPERTIES = Inspec::Resource.registry["aws_subnet"].instance_methods &
57
- Inspec::Resource.registry["directory"].instance_methods
55
+ def self.cfn_controls(title, generated_controls, stack)
56
+ content = "# encoding: utf-8\n#\n\n"
57
+
58
+ content += "begin\n"
59
+ content += " awsclient = Aws::CloudFormation::Client.new()\n"
60
+ content += " cfn = awsclient.list_stack_resources({ stack_name: \"#{stack}\" }).to_hash\n"
61
+ content += " resources = {}\n"
62
+ content += " cfn[:stack_resource_summaries].each { |r| resources[r[:logical_resource_id]] = r[:physical_resource_id] }\n"
63
+ content += "rescue Exception => e\n"
64
+ content += " raise(e) unless @conf['profile'].check_mode\n"
65
+ content += "end\n\n"
66
+
67
+ content += "title \"#{title}: generated by Iggy v#{Iggy::VERSION}\"\n"
68
+
69
+ # get the controls, insert lookups for physical_resource_ids
70
+ controls = generated_controls.flatten.map(&:to_ruby).join("\n\n")
71
+ controls.gsub!(/\"resources\[/, 'resources["')
72
+ controls.gsub!(/\]\"/, '"]')
73
+ content + controls
74
+ end
75
+
76
+ # a hack for sure, finds common methods as proxy for InSpec properties
77
+ COMMON_PROPERTIES = Inspec::Resource.registry['aws_subnet'].instance_methods &
78
+ Inspec::Resource.registry['directory'].instance_methods
79
+ end
58
80
  end
59
81
  end
@@ -0,0 +1,43 @@
1
+ # encoding: UTF-8
2
+
3
+ # Plugin Definition file
4
+ # The purpose of this file is to declare to InSpec what plugin_types (capabilities)
5
+ # are included in this plugin, and provide hooks that will load them as needed.
6
+
7
+ # It is important that this file load successfully and *quickly*.
8
+ # Your plugin's functionality may never be used on this InSpec run; so we keep things
9
+ # fast and light by only loading heavy things when they are needed.
10
+
11
+ require 'inspec/plugin/v2'
12
+
13
+ # The InspecPlugins namespace is where all plugins should declare themselves.
14
+ # The 'Inspec' capitalization is used throughout the InSpec source code; yes, it's
15
+ # strange.
16
+ module InspecPlugins
17
+ # Pick a reasonable namespace here for your plugin. A reasonable choice
18
+ # would be the CamelCase version of your plugin gem name.
19
+ module Iggy
20
+ class Plugin < ::Inspec.plugin(2)
21
+ # Internal machine name of the plugin. InSpec will use this in errors, etc.
22
+ plugin_name :'inspec-iggy'
23
+
24
+ cli_command :terraform do
25
+ # Calling this hook doesn't mean iggy is being executed - just
26
+ # that we should be ready to do so. So, load the file that defines the
27
+ # functionality.
28
+ # For example, InSpec will activate this hook when `inspec help` is
29
+ # executed, so that this plugin's usage message will be included in the help.
30
+ require 'inspec-iggy/terraform/cli_command'
31
+
32
+ # Having loaded our functionality, return a class that will let the
33
+ # CLI engine tap into it.
34
+ InspecPlugins::Iggy::Terraform::CliCommand
35
+ end
36
+
37
+ cli_command :cloudformation do
38
+ require 'inspec-iggy/cloudformation/cli_command'
39
+ InspecPlugins::Iggy::CloudFormation::CliCommand
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,74 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Author:: Matt Ray (<matt@chef.io>)
4
+ #
5
+ # Copyright:: 2018, Chef Software, Inc <legal@chef.io>
6
+ #
7
+
8
+ require 'yaml'
9
+
10
+ module InspecPlugins
11
+ module Iggy
12
+ class Profile
13
+ # match the output of 'inspec init profile'
14
+ def self.render_profile(cli_ui, options, source_file, controls)
15
+ name = options[:name]
16
+ overwrite_mode = options[:overwrite]
17
+ # Create new profile at /Users/mattray/ws/inspec-iggy/foobar
18
+ full_destination_root_path = Pathname.new(Dir.pwd).join(name)
19
+ cli_ui.plain_text "Create new profile at #{cli_ui.mark_text(full_destination_root_path)}"
20
+ if File.exist?(full_destination_root_path) && !overwrite_mode
21
+ cli_ui.plain_text "#{cli_ui.mark_text(full_destination_root_path)} exists already, use --overwrite"
22
+ cli_ui.exit(1)
23
+ end
24
+ # ensure that full_destination_root_path directory is available
25
+ FileUtils.mkdir_p(full_destination_root_path)
26
+ # * Create directory controls
27
+ cli_ui.li "Create directory #{cli_ui.mark_text("#{name}/controls")}"
28
+ FileUtils.mkdir_p("#{name}/controls")
29
+ render_readme_md(cli_ui, name, source_file)
30
+ render_inspec_yml(cli_ui, name, source_file, options)
31
+ render_controls_rb(cli_ui, name, controls)
32
+ end
33
+
34
+ # * Create file README.md
35
+ def self.render_readme_md(cli_ui, name, source_file)
36
+ render_file = "#{name}/README.md"
37
+ cli_ui.li "Create file #{cli_ui.mark_text(render_file)}"
38
+ f = File.new(render_file, 'w')
39
+ f.puts("# #{name}")
40
+ f.puts
41
+ f.puts("This profile was generated by InSpec-Iggy v#{Iggy::VERSION} from the #{source_file} source file.")
42
+ f.close
43
+ end
44
+
45
+ # * Create file inspec.yml
46
+ def self.render_inspec_yml(cli_ui, name, source_file, options)
47
+ render_file = "#{name}/inspec.yml"
48
+ cli_ui.li "Create file #{cli_ui.mark_text(render_file)}"
49
+ yml = {}
50
+ yml['name'] = name
51
+ yml['title'] = options[:title]
52
+ yml['maintainer'] = options[:maintainer]
53
+ yml['copyright'] = options[:copyright]
54
+ yml['copyright_email'] = options[:email]
55
+ yml['license'] = options[:license]
56
+ yml['summary'] = options[:summary]
57
+ yml['version'] = options[:version]
58
+ yml['description'] = "Generated by InSpec-Iggy v#{Iggy::VERSION} from the #{source_file} source file."
59
+ f = File.new(render_file, 'w')
60
+ f.write(yml.to_yaml)
61
+ f.close
62
+ end
63
+
64
+ # * Create file controls/example.rb
65
+ def self.render_controls_rb(cli_ui, name, controls)
66
+ render_file = "#{name}/controls/controls.rb"
67
+ cli_ui.li "Create file #{cli_ui.mark_text(render_file)}"
68
+ f = File.new(render_file, 'w')
69
+ f.write(controls)
70
+ f.close
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,94 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Matt Ray (<matt@chef.io>)
4
+ #
5
+ # Copyright:: 2018, Chef Software, Inc <legal@chef.io>
6
+ #
7
+
8
+ require 'inspec/plugin/v2'
9
+
10
+ require 'inspec-iggy/version'
11
+ require 'inspec-iggy/profile'
12
+ require 'inspec-iggy/terraform/parser'
13
+
14
+ module InspecPlugins::Iggy
15
+ module Terraform
16
+ class CliCommand < Inspec.plugin(2, :cli_command)
17
+ subcommand_desc 'terraform SUBCOMMAND ...', 'Extract or generate InSpec from Terraform'
18
+
19
+ # Thor.map(Hash) allows you to make aliases for commands.
20
+ map('-v' => 'version') # Treat `inspec terraform -v`` as `inspec terraform version`
21
+ map('--version' => 'version') # Treat `inspec terraform -version`` as `inspec terraform version`
22
+
23
+ desc 'version', 'Display version information', hide: true
24
+ def version
25
+ say("Iggy v#{InspecPlugins::Iggy::VERSION}")
26
+ end
27
+
28
+ option :debug,
29
+ desc: 'Verbose debugging messages',
30
+ type: :boolean,
31
+ default: false
32
+
33
+ option :copyright,
34
+ desc: 'Name of the copyright holder',
35
+ default: 'The Authors'
36
+
37
+ option :email,
38
+ desc: 'Email address of the author',
39
+ default: 'you@example.com'
40
+
41
+ option :license,
42
+ desc: 'License for the profile',
43
+ default: 'Apache-2.0'
44
+
45
+ option :maintainer,
46
+ desc: 'Name of the copyright holder',
47
+ default: 'The Authors'
48
+
49
+ option :summary,
50
+ desc: 'One line summary for the profile',
51
+ default: 'An InSpec Compliance Profile'
52
+
53
+ option :title,
54
+ desc: 'Human-readable name for the profile',
55
+ default: 'InSpec Profile'
56
+
57
+ option :version,
58
+ desc: 'Specify the profile version',
59
+ default: '0.1.0'
60
+
61
+ option :overwrite,
62
+ desc: 'Overwrites existing profile directory',
63
+ type: :boolean,
64
+ default: false
65
+
66
+ option :name,
67
+ aliases: '-n',
68
+ required: true,
69
+ desc: 'Name of profile to be generated'
70
+
71
+ option :tfstate,
72
+ aliases: '-t',
73
+ desc: 'Specify path to the input terraform.tfstate',
74
+ default: 'terraform.tfstate'
75
+
76
+ desc 'generate [options]', 'Generate InSpec compliance controls from terraform.tfstate'
77
+ def generate
78
+ Inspec::Log.level = :debug if options[:debug]
79
+ generated_controls = InspecPlugins::Iggy::Terraform::Parser.parse_generate(options[:tfstate])
80
+ printable_controls = InspecPlugins::Iggy::InspecHelper.tf_controls(options[:title], generated_controls)
81
+ InspecPlugins::Iggy::Profile.render_profile(self, options, options[:tfstate], printable_controls)
82
+ exit 0
83
+ end
84
+
85
+ desc 'extract [options]', 'Extract tagged InSpec profiles from terraform.tfstate'
86
+ def extract
87
+ Inspec::Log.level = :debug if options[:debug]
88
+ extracted_profiles = InspecPlugins::Iggy::Terraform::Parser.parse_extract(options[:tfstate])
89
+ puts InspecPlugins::Iggy::InspecHelper.print_commands(extracted_profiles)
90
+ exit 0
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,147 @@
1
+ #
2
+ # Author:: Matt Ray (<matt@chef.io>)
3
+ #
4
+ # Copyright:: 2018, Chef Software, Inc <legal@chef.io>
5
+ #
6
+
7
+ require 'json'
8
+
9
+ require 'inspec/objects/control'
10
+ require 'inspec/objects/ruby_helper'
11
+ require 'inspec/objects/describe'
12
+
13
+ require 'inspec-iggy/inspec_helper'
14
+
15
+ module InspecPlugins::Iggy::Terraform
16
+ class Parser
17
+ # makes it easier to change out later
18
+ TAG_NAME = 'iggy_name_'.freeze
19
+ TAG_URL = 'iggy_url_'.freeze
20
+
21
+ # boilerplate tfstate parsing
22
+ def self.parse_tfstate(file)
23
+ Inspec::Log.debug "Iggy::Terraform.parse_tfstate file = #{file}"
24
+ begin
25
+ unless File.file?(file)
26
+ STDERR.puts "ERROR: #{file} is an invalid file, please check your path."
27
+ exit(-1)
28
+ end
29
+ JSON.parse(File.read(file))
30
+ rescue JSON::ParserError => e
31
+ STDERR.puts e.message
32
+ STDERR.puts "ERROR: Parsing error in #{file}."
33
+ exit(-1)
34
+ end
35
+ end
36
+
37
+ # parse through the JSON for the tagged Resources
38
+ def self.parse_extract(file) # rubocop:disable Metrics/AbcSize
39
+ tfstate = parse_tfstate(file)
40
+ # InSpec profiles extracted
41
+ extracted_profiles = {}
42
+
43
+ # iterate over the resources
44
+ tf_resources = tfstate['modules'][0]['resources']
45
+ tf_resources.keys.each do |tf_res|
46
+ tf_res_id = tf_resources[tf_res]['primary']['id']
47
+
48
+ # get the attributes, see if any of them have a tagged profile attached
49
+ tf_resources[tf_res]['primary']['attributes'].keys.each do |attr|
50
+ next unless attr.start_with?('tags.' + TAG_NAME)
51
+ Inspec::Log.debug "Iggy::Terraform.parse_extract tf_res = #{tf_res} attr = #{attr} MATCHED TAG"
52
+ # get the URL and the name of the profiles
53
+ name = attr.split(TAG_NAME)[1]
54
+ url = tf_resources[tf_res]['primary']['attributes']["tags.#{TAG_URL}#{name}"]
55
+ if tf_res.start_with?('aws_vpc') # should this be VPC or subnet?
56
+ # if it's a VPC, store it as the VPC id + name
57
+ key = tf_res_id + ':' + name
58
+ Inspec::Log.debug "Iggy::Terraform.parse_extract aws_vpc tagged with InSpec #{key}"
59
+ extracted_profiles[key] = {
60
+ 'type' => 'aws_vpc',
61
+ 'az' => 'us-west-2',
62
+ 'url' => url,
63
+ }
64
+ elsif tf_res.start_with?('aws_instance')
65
+ # if it's a node, get information about the IP and SSH/WinRM
66
+ key = tf_res_id + ':' + name
67
+ Inspec::Log.debug "Iggy::Terraform.parse_extract aws_instance tagged with InSpec #{key}"
68
+ extracted_profiles[key] = {
69
+ 'type' => 'aws_instance',
70
+ 'public_ip' => tf_resources[tf_res]['primary']['attributes']['public_ip'],
71
+ 'key_name' => tf_resources[tf_res]['primary']['attributes']['key_name'],
72
+ 'url' => url,
73
+ }
74
+ else
75
+ # should generic AWS just be the default except for instances?
76
+ STDERR.puts "ERROR: #{file} #{tf_res_id} has an InSpec-tagged resource but #{tf_res} is currently unsupported."
77
+ exit(-1)
78
+ end
79
+ end
80
+ end
81
+ Inspec::Log.debug "Iggy::Terraform.parse_extract extracted_profiles = #{extracted_profiles}"
82
+ extracted_profiles
83
+ end
84
+
85
+ # parse through the JSON and generate InSpec controls
86
+ def self.parse_generate(file) # rubocop:disable all
87
+ tfstate = parse_tfstate(file)
88
+ absolutename = File.absolute_path(file)
89
+
90
+ # InSpec controls generated
91
+ generated_controls = []
92
+
93
+ # iterate over the resources
94
+ tfstate['modules'].each do |m|
95
+ tf_resources = m['resources']
96
+ tf_resources.keys.each do |tf_res|
97
+ tf_res_type = tf_resources[tf_res]['type']
98
+
99
+ # add translation layer
100
+ if InspecPlugins::Iggy::InspecHelper::TRANSLATED_RESOURCES.key?(tf_res_type)
101
+ Inspec::Log.debug "Iggy::Terraform.parse_generate tf_res_type = #{tf_res_type} #{InspecPlugins::Iggy::InspecHelper::TRANSLATED_RESOURCES[tf_res_type]} TRANSLATED"
102
+ tf_res_type = InspecPlugins::Iggy::InspecHelper::TRANSLATED_RESOURCES[tf_res_type]
103
+ end
104
+
105
+ # does this match an InSpec resource?
106
+ if InspecPlugins::Iggy::InspecHelper::RESOURCES.include?(tf_res_type)
107
+ Inspec::Log.debug "Iggy::Terraform.parse_generate tf_res_type = #{tf_res_type} MATCH"
108
+ tf_res_id = tf_resources[tf_res]['primary']['id']
109
+
110
+ # insert new control based off the resource's ID
111
+ ctrl = Inspec::Control.new
112
+ ctrl.id = "#{tf_res_type}::#{tf_res_id}"
113
+ ctrl.title = "InSpec-Iggy #{tf_res_type}::#{tf_res_id}"
114
+ ctrl.descriptions[:default] = "#{tf_res_type}::#{tf_res_id} from the source file #{absolutename}\nGenerated by InSpec-Iggy v#{InspecPlugins::Iggy::VERSION}"
115
+ ctrl.impact = '1.0'
116
+
117
+ describe = Inspec::Describe.new
118
+ # describes the resourde with the id as argument
119
+ describe.qualifier.push([tf_res_type, tf_res_id])
120
+
121
+ # ensure the resource exists
122
+ describe.add_test(nil, 'exist', nil)
123
+
124
+ # if there's a match, see if there are matching InSpec properties
125
+ inspec_properties = InspecPlugins::Iggy::InspecHelper.resource_properties(tf_res_type)
126
+ tf_resources[tf_res]['primary']['attributes'].keys.each do |attr|
127
+ if inspec_properties.member?(attr)
128
+ Inspec::Log.debug "Iggy::Terraform.parse_generate #{tf_res_type} inspec_property = #{attr} MATCH"
129
+ value = tf_resources[tf_res]['primary']['attributes'][attr]
130
+ describe.add_test(attr, 'eq', value)
131
+ else
132
+ Inspec::Log.debug "Iggy::Terraform.parse_generate #{tf_res_type} inspec_property = #{attr} SKIP"
133
+ end
134
+ end
135
+
136
+ ctrl.add_test(describe)
137
+ generated_controls.push(ctrl)
138
+ else
139
+ Inspec::Log.debug "Iggy::Terraform.parse_generate tf_res_type = #{tf_res_type} SKIP"
140
+ end
141
+ end
142
+ end
143
+ Inspec::Log.debug "Iggy::Terraform.parse_generate generated_controls = #{generated_controls}"
144
+ generated_controls
145
+ end
146
+ end
147
+ end