inspec-iggy 0.2.0 → 0.4.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.
@@ -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