From a16e9fcf45192c124a50204bf0b3cf411f87dca9 Mon Sep 17 00:00:00 2001 From: Nikita Mathur Date: Fri, 9 Jul 2021 19:03:14 +0530 Subject: [PATCH 1/3] Tag based filtering for controls - initial commit Signed-off-by: Nikita Mathur --- docs-chef-io/content/inspec/cli.md | 4 ++ lib/inspec/base_cli.rb | 2 + lib/inspec/cli.rb | 2 + lib/inspec/control_eval_context.rb | 45 ++++++++++++++++++- lib/inspec/profile.rb | 27 ++++++++++- lib/inspec/runner.rb | 2 + .../profiles/control-tags/controls/example.rb | 29 ++++++++++++ .../fixtures/profiles/control-tags/inspec.yml | 7 +++ test/functional/inspec_exec_test.rb | 45 +++++++++++++++++++ 9 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/profiles/control-tags/controls/example.rb create mode 100644 test/fixtures/profiles/control-tags/inspec.yml diff --git a/docs-chef-io/content/inspec/cli.md b/docs-chef-io/content/inspec/cli.md index ca822c988..a8c8fe08e 100644 --- a/docs-chef-io/content/inspec/cli.md +++ b/docs-chef-io/content/inspec/cli.md @@ -272,6 +272,8 @@ This subcommand has additional options: Read configuration from JSON file (`-` reads from stdin). * ``--controls=one two three`` A list of control names to run, or a list of /regexes/ to match against control names. Ignore all other tests. +* ``--tags=one two three`` + A list of tags names that are part of controls to filter and run controls, or a list of /regexes/ to match against tags names of controls. Ignore all other tests. And when tag on the control is a hashmap, it only uses values of hashmap for filtering controls. * ``--create-lockfile``, ``--no-create-lockfile`` Write out a lockfile based on this execution (unless one already exists) * ``--distinct-exit``, ``--no-distinct-exit`` @@ -379,6 +381,8 @@ This subcommand has additional options: * ``--controls=one two three`` A list of controls to include. Ignore all other tests. +* ``--tags=one two three`` + A list of tags to filter controls and include only those. Ignore all other tests. * ``-o``, ``--output=OUTPUT`` Save the created profile to a path * ``--profiles-path=PROFILES_PATH`` diff --git a/lib/inspec/base_cli.rb b/lib/inspec/base_cli.rb index 337c6930f..46ecc61f1 100644 --- a/lib/inspec/base_cli.rb +++ b/lib/inspec/base_cli.rb @@ -136,6 +136,8 @@ module Inspec profile_options option :controls, type: :array, desc: "A list of control names to run, or a list of /regexes/ to match against control names. Ignore all other tests." + option :tags, type: :array, + desc: "A list of tags names that are part of controls to filter and run controls, or a list of /regexes/ to match against tags names of controls. Ignore all other tests." option :reporter, type: :array, banner: "one two:/output/file/path", desc: "Enable one or more output reporters: cli, documentation, html, progress, json, json-min, json-rspec, junit, yaml" diff --git a/lib/inspec/cli.rb b/lib/inspec/cli.rb index 5f4a97e8c..5ed5baa91 100644 --- a/lib/inspec/cli.rb +++ b/lib/inspec/cli.rb @@ -65,6 +65,8 @@ class Inspec::InspecCLI < Inspec::BaseCLI desc: "Save the created profile to a path" option :controls, type: :array, desc: "A list of controls to include. Ignore all other tests." + option :tags, type: :array, + desc: "A list of tags to filter controls and include only those. Ignore all other tests." profile_options def json(target) require "json" unless defined?(JSON) diff --git a/lib/inspec/control_eval_context.rb b/lib/inspec/control_eval_context.rb index fd38d7d4f..019ad0b2a 100644 --- a/lib/inspec/control_eval_context.rb +++ b/lib/inspec/control_eval_context.rb @@ -53,12 +53,23 @@ module Inspec def control(id, opts = {}, &block) opts[:skip_only_if_eval] = @skip_only_if_eval - if control_exist_in_controls_list?(id) || controls_list_empty? + tag_ids = control_tags(&block) + if (controls_list_empty? && tags_list_empty?) || control_exist_in_controls_list?(id) || tag_exist_in_control_tags?(tag_ids) register_control(Inspec::Rule.new(id, profile_id, resources_dsl, opts, &block)) end end + alias rule control + def control_tags(&block) + tag_source = block.source.split("\n").select { |src| src.split.first.eql?("tag") } + tag_source = tag_source.map { |src| src.sub("tag", "").strip }.map { |src| src.split(",").map { |final_src| final_src.gsub(/([^:]*):/, "") } }.flatten + output = tag_source.map { |src| src.gsub(/\[|\]/, "") }.map { |src| instance_eval(src) } + output.compact.uniq + rescue => e + raise "Unable to fetch control tags: #{e.class} -- #{e.message}" + end + # Describe allows users to write rspec-like bare describe # blocks without declaring an inclosing control. Here, we # generate a control for them automatically and then execute @@ -74,7 +85,9 @@ module Inspec res = describe(*args, &block) end - if control_exist_in_controls_list?(id) || controls_list_empty? + tag_ids = control_tags(&block) + + if (controls_list_empty? && tags_list_empty?) || control_exist_in_controls_list?(id) || tag_exist_in_control_tags?(tag_ids) register_control(rule, &block) end @@ -187,11 +200,19 @@ module Inspec !@conf.empty? && @conf.key?("profile") && !@conf["profile"].include_controls_list.empty? end + def profile_tag_config_exist? + !@conf.empty? && @conf.key?("profile") && !@conf["profile"].include_tags_list.empty? + end + # Returns true if configuration hash is empty or configuration hash does not have the list of controls that needs to be included def controls_list_empty? !@conf.empty? && @conf.key?("profile") && @conf["profile"].include_controls_list.empty? || @conf.empty? end + def tags_list_empty? + !@conf.empty? && @conf.key?("profile") && @conf["profile"].include_tags_list.empty? || @conf.empty? + end + # Check if the given control exist in the --controls option def control_exist_in_controls_list?(id) id_exist_in_list = false @@ -203,5 +224,25 @@ module Inspec end id_exist_in_list end + + # Check if the given control exist in the --tags option + def tag_exist_in_control_tags?(tag_ids) + tag_option_matches_with_list = false + if !tag_ids.empty? && !tag_ids.nil? && profile_tag_config_exist? + tag_option_matches_with_list = !(tag_ids & @conf["profile"].include_tags_list).empty? + unless tag_option_matches_with_list + @conf["profile"].include_tags_list.any? do |inclusion| + # Try to see if the inclusion is a regex, and if it matches + if inclusion.is_a?(Regexp) + tag_ids.each do |id| + tag_option_matches_with_list = (inclusion =~ id) + break if tag_option_matches_with_list + end + end + end + end + end + tag_option_matches_with_list + end end end diff --git a/lib/inspec/profile.rb b/lib/inspec/profile.rb index d6ef8736f..5444e3f11 100644 --- a/lib/inspec/profile.rb +++ b/lib/inspec/profile.rb @@ -87,6 +87,7 @@ module Inspec @logger = options[:logger] || Logger.new(nil) @locked_dependencies = options[:dependencies] @controls = options[:controls] || [] + @tags = options[:tags] || [] @writable = options[:writable] || false @profile_id = options[:id] @profile_name = options[:profile_name] @@ -206,7 +207,7 @@ module Inspec @params ||= load_params end - def collect_tests(include_list = @controls) + def collect_tests unless @tests_collected || failed? return unless supports_platform? @@ -253,6 +254,30 @@ module Inspec included_controls end + # This creates the list of controls to be filtered by tag values provided in the --tags options + def include_tags_list + return [] if @tags.nil? || @tags.empty? + + included_tags = @tags + # Check for anything that might be a regex in the list, and make it official + included_tags.each_with_index do |inclusion, index| + next if inclusion.is_a?(Regexp) + # Insist the user wrap the regex in slashes to demarcate it as a regex + next unless inclusion.start_with?("/") && inclusion.end_with?("/") + + inclusion = inclusion[1..-2] # Trim slashes + begin + re = Regexp.new(inclusion) + included_tags[index] = re + rescue RegexpError => e + warn "Ignoring unparseable regex '/#{inclusion}/' in --control CLI option: #{e.message}" + included_tags[index] = nil + end + end + included_tags.compact! + included_tags + end + def load_libraries return @runner_context if @libraries_loaded diff --git a/lib/inspec/runner.rb b/lib/inspec/runner.rb index 7ba10bcc0..0dd48fdfc 100644 --- a/lib/inspec/runner.rb +++ b/lib/inspec/runner.rb @@ -50,6 +50,7 @@ module Inspec @conf[:logger] ||= Logger.new(nil) @target_profiles = [] @controls = @conf[:controls] || [] + @tags = @conf[:tags] || [] @depends = @conf[:depends] || [] @create_lockfile = @conf[:create_lockfile] @cache = Inspec::Cache.new(@conf[:vendor_cache]) @@ -199,6 +200,7 @@ module Inspec vendor_cache: @cache, backend: @backend, controls: @controls, + tags: @tags, runner_conf: @conf) raise "Could not resolve #{target} to valid input." if profile.nil? diff --git a/test/fixtures/profiles/control-tags/controls/example.rb b/test/fixtures/profiles/control-tags/controls/example.rb new file mode 100644 index 000000000..b96cfdc2b --- /dev/null +++ b/test/fixtures/profiles/control-tags/controls/example.rb @@ -0,0 +1,29 @@ +control "basic" do + tag "tag1" + tag severity: nil + tag data: "tag2" + tag data_arr: ["tag3", "tag4"] + describe(true) { it { should eq true } } +end + +control "tag keyword used in control name and tag value" do + tag "tag5" + describe(true) { it { should eq true } } +end + +control "multiple tags in one line" do + tag "tag6", "tag7", "tagname with space" + tag data1: "tag8", data2: "tag9" + tag data_arr1: ["tag10", "tag11"], data_arr2: ["tag12", "tag13"] + describe(true) { it { should eq true } } +end + +control "all different formats of tags in one line" do + tag "tag14", data: "tag15", data_arr: ["tag16", "tag17"] + describe(true) { it { should eq true } } +end + +control "failure control" do + tag "tag18" + describe(true) { it { should eq false } } +end \ No newline at end of file diff --git a/test/fixtures/profiles/control-tags/inspec.yml b/test/fixtures/profiles/control-tags/inspec.yml new file mode 100644 index 000000000..3f6f25a6b --- /dev/null +++ b/test/fixtures/profiles/control-tags/inspec.yml @@ -0,0 +1,7 @@ +name: control-tags +title: InSpec Profile for testing filtering on controls using tags +license: Apache-2.0 +summary: An InSpec Compliance Profile for testing filtering on controls using tags +version: 0.1.0 +supports: + platform: os \ No newline at end of file diff --git a/test/functional/inspec_exec_test.rb b/test/functional/inspec_exec_test.rb index b4410ca26..b64fac272 100644 --- a/test/functional/inspec_exec_test.rb +++ b/test/functional/inspec_exec_test.rb @@ -237,6 +237,51 @@ Test Summary: 0 successful, 0 failures, 0 skipped assert_exit_code 100, out end + it "executes only specified controls when selecting the controls by literal single tag name" do + inspec("exec " + File.join(profile_path, "control-tags") + " --no-create-lockfile --tags tag1") + _(stdout).must_include "true is expected to eq true\n" + _(stdout).must_include "Test Summary: 1 successful, 0 failures, 0 skipped\n" + _(stderr).must_equal "" + + assert_exit_code 0, out + end + + it "executes only specified controls when selecting the controls by literal multiple tag names" do + inspec("exec " + File.join(profile_path, "control-tags") + " --no-create-lockfile --tags tag1 tag5 tag6 tag17 'tagname with space'") + _(stdout).must_include "true is expected to eq true\n" + _(stdout).must_include "Test Summary: 4 successful, 0 failures, 0 skipped\n" + _(stderr).must_equal "" + + assert_exit_code 0, out + end + + it "executes only specified controls when selecting the controls by using regex on tags" do + inspec("exec " + File.join(profile_path, "control-tags") + " --no-create-lockfile --tags '/\s+/'") + _(stdout).must_include "true is expected to eq true\n" + _(stdout).must_include "Test Summary: 1 successful, 0 failures, 0 skipped\n" + _(stderr).must_equal "" + + assert_exit_code 0, out + end + + it "executes only specified controls when selecting failing controls by using literal name of tag" do + inspec("exec " + File.join(profile_path, "control-tags") + " --no-create-lockfile --tags tag18") + _(stdout).must_include "true is expected to eq false\n" + _(stdout).must_include "Test Summary: 0 successful, 1 failure, 0 skipped\n" + _(stderr).must_equal "" + + assert_exit_code 100, out + end + + it "executes only specified controls when selecting failing controls by using regex on tags" do + inspec("exec " + File.join(profile_path, "control-tags") + " --no-create-lockfile --tags '/(18)/'") + _(stdout).must_include "true is expected to eq false\n" + _(stdout).must_include "Test Summary: 0 successful, 1 failure, 0 skipped\n" + _(stderr).must_equal "" + + assert_exit_code 100, out + end + it "reports whan a profile cannot be loaded" do inspec("exec " + File.join(profile_path, "raise_outside_control") + " --no-create-lockfile") _(stdout).must_match(/Profile:[\W]+InSpec Profile \(raise_outside_control\)/) From 8e755063c5bf71b089e710e1a3cae466e6534a1b Mon Sep 17 00:00:00 2001 From: Nikita Mathur Date: Fri, 16 Jul 2021 14:09:33 +0530 Subject: [PATCH 2/3] Doc review changes for --tags option Signed-off-by: Nikita Mathur --- docs-chef-io/content/inspec/cli.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-chef-io/content/inspec/cli.md b/docs-chef-io/content/inspec/cli.md index a8c8fe08e..43bfe77eb 100644 --- a/docs-chef-io/content/inspec/cli.md +++ b/docs-chef-io/content/inspec/cli.md @@ -272,8 +272,6 @@ This subcommand has additional options: Read configuration from JSON file (`-` reads from stdin). * ``--controls=one two three`` A list of control names to run, or a list of /regexes/ to match against control names. Ignore all other tests. -* ``--tags=one two three`` - A list of tags names that are part of controls to filter and run controls, or a list of /regexes/ to match against tags names of controls. Ignore all other tests. And when tag on the control is a hashmap, it only uses values of hashmap for filtering controls. * ``--create-lockfile``, ``--no-create-lockfile`` Write out a lockfile based on this execution (unless one already exists) * ``--distinct-exit``, ``--no-distinct-exit`` @@ -338,6 +336,8 @@ This subcommand has additional options: Simple targeting option using URIs, e.g. ssh://user:pass@host:port * ``--target-id=TARGET_ID`` Provide a ID which will be included on reports +* ``--tags=one two three`` + A list of tags, a list of regular expressions that match tags, or a hash map where each value is a tag. `exec` will run controls referenced by the listed or matching tags. * ``--user=USER`` The login user for a remote scan. * ``--vendor-cache=VENDOR_CACHE`` @@ -381,12 +381,12 @@ This subcommand has additional options: * ``--controls=one two three`` A list of controls to include. Ignore all other tests. -* ``--tags=one two three`` - A list of tags to filter controls and include only those. Ignore all other tests. * ``-o``, ``--output=OUTPUT`` Save the created profile to a path * ``--profiles-path=PROFILES_PATH`` Folder which contains referenced profiles. +* ``--tags=one two three`` + A list of tags that reference certain controls. Other controls are ignored. * ``--vendor-cache=VENDOR_CACHE`` Use the given path for caching dependencies. (default: ~/.inspec/cache) From aeed833f2f85124a744ddee4d3848255b5edb00f Mon Sep 17 00:00:00 2001 From: Nikita Mathur Date: Mon, 26 Jul 2021 12:51:35 +0530 Subject: [PATCH 3/3] Build fix Signed-off-by: Nikita Mathur --- lib/inspec/control_eval_context.rb | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/inspec/control_eval_context.rb b/lib/inspec/control_eval_context.rb index 019ad0b2a..a062d84a5 100644 --- a/lib/inspec/control_eval_context.rb +++ b/lib/inspec/control_eval_context.rb @@ -63,11 +63,11 @@ module Inspec def control_tags(&block) tag_source = block.source.split("\n").select { |src| src.split.first.eql?("tag") } - tag_source = tag_source.map { |src| src.sub("tag", "").strip }.map { |src| src.split(",").map { |final_src| final_src.gsub(/([^:]*):/, "") } }.flatten - output = tag_source.map { |src| src.gsub(/\[|\]/, "") }.map { |src| instance_eval(src) } + tag_source = tag_source.map { |src| src.sub("tag", "").strip }.map { |src| src.split(",").map { |final_src| final_src.sub(/([^:]*):/, "") } }.flatten + output = tag_source.map { |src| src.sub(/\[|\]/, "") }.map { |src| instance_eval(src) } output.compact.uniq - rescue => e - raise "Unable to fetch control tags: #{e.class} -- #{e.message}" + rescue + [] end # Describe allows users to write rspec-like bare describe @@ -85,9 +85,7 @@ module Inspec res = describe(*args, &block) end - tag_ids = control_tags(&block) - - if (controls_list_empty? && tags_list_empty?) || control_exist_in_controls_list?(id) || tag_exist_in_control_tags?(tag_ids) + if controls_list_empty? || control_exist_in_controls_list?(id) register_control(rule, &block) end