diff --git a/lib/bundles/inspec-compliance/README.md b/lib/bundles/inspec-compliance/README.md index 8e761db82..facd4d9c6 100644 --- a/lib/bundles/inspec-compliance/README.md +++ b/lib/bundles/inspec-compliance/README.md @@ -37,6 +37,14 @@ Commands: inspec compliance version # displays the version of the Chef Compliance server ``` +### Login with Chef Automate2 + +You will need an API token for authentication. You can retrieve one via the admin section of your A2 web gui. + +``` +$ inspec compliance login https://automate2.compliance.test --insecure --user 'admin' --token 'zuop..._KzE' +``` + ### Login with Chef Automate You will need an access token for authentication. You can retrieve one via [UI](https://docs.chef.io/api_delivery.html) or [CLI](https://docs.chef.io/ctl_delivery.html#delivery-token). diff --git a/lib/bundles/inspec-compliance/api.rb b/lib/bundles/inspec-compliance/api.rb index d67a05730..7e6c7e36c 100755 --- a/lib/bundles/inspec-compliance/api.rb +++ b/lib/bundles/inspec-compliance/api.rb @@ -4,6 +4,7 @@ require 'net/http' require 'uri' +require 'json' require_relative 'api/login' @@ -18,12 +19,15 @@ module Compliance # return all compliance profiles available for the user # the user is either specified in the options hash or by default # the username of the account is used that is logged in - def self.profiles(config) + def self.profiles(config) # rubocop:disable PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength owner = config['owner'] || config['user'] # Chef Compliance if is_compliance_server?(config) url = "#{config['server']}/user/compliance" + # Chef Automate2 + elsif is_automate2_server?(config) + url = "#{config['server']}/compliance/profiles/search" # Chef Automate elsif is_automate_server?(config) url = "#{config['server']}/profiles/#{owner}" @@ -32,7 +36,13 @@ module Compliance end headers = get_headers(config) - response = Compliance::HTTP.get(url, headers, config['insecure']) + + if is_automate2_server?(config) + body = { owner: owner }.to_json + response = Compliance::HTTP.post_with_headers(url, headers, body, config['insecure']) + else + response = Compliance::HTTP.get(url, headers, config['insecure']) + end data = response.body response_code = response.code case response_code @@ -48,6 +58,11 @@ module Compliance # Chef Automate pre 0.8.0 elsif is_automate_server_pre_080?(config) mapped_profiles = profiles.values.flatten + elsif is_automate2_server?(config) + mapped_profiles = [] + profiles['profiles'].each { |p| + mapped_profiles << p + } else mapped_profiles = profiles.map { |e| e['owner_id'] = owner @@ -97,7 +112,8 @@ module Compliance if !profiles.empty? profiles.any? do |p| - p['owner_id'] == owner && + profile_owner = p['owner_id'] || p['owner'] + profile_owner == owner && p['name'] == id && (ver.nil? || p['version'] == ver) end @@ -113,13 +129,20 @@ module Compliance # Chef Automate pre 0.8.0 elsif is_automate_server_pre_080?(config) url = "#{config['server']}/#{owner}" + elsif is_automate2_server?(config) + url = "#{config['server']}/compliance/profiles?owner=#{owner}" # Chef Automate else url = "#{config['server']}/profiles/#{owner}" end headers = get_headers(config) - res = Compliance::HTTP.post_file(url, headers, archive_path, config['insecure']) + if is_automate2_server?(config) + res = Compliance::HTTP.post_multipart_file(url, headers, archive_path, config['insecure']) + else + res = Compliance::HTTP.post_file(url, headers, archive_path, config['insecure']) + end + [res.is_a?(Net::HTTPSuccess), res.body] end @@ -173,7 +196,7 @@ module Compliance def self.get_headers(config) token = get_token(config) - if is_automate_server?(config) + if is_automate_server?(config) || is_automate2_server?(config) headers = { 'chef-delivery-enterprise' => config['automate']['ent'] } if config['automate']['token_type'] == 'dctoken' headers['x-data-collector-token'] = token @@ -196,6 +219,7 @@ module Compliance def self.target_url(config, profile) owner, id, ver = profile_split(profile) + return "#{config['server']}/compliance/profiles/tar" if is_automate2_server?(config) return "#{config['server']}/owners/#{owner}/compliance/#{id}/tar" unless is_automate_server?(config) if ver.nil? @@ -238,6 +262,10 @@ module Compliance !server_version_from_config(config).nil? end + def self.is_automate2_server?(config) + config['server_type'] == 'automate2' + end + def self.is_automate_server?(config) config['server_type'] == 'automate' end @@ -251,7 +279,9 @@ module Compliance end def self.determine_server_type(url, insecure) - if target_is_automate_server?(url, insecure) + if target_is_automate2_server?(url, insecure) + :automate2 + elsif target_is_automate_server?(url, insecure) :automate elsif target_is_compliance_server?(url, insecure) :compliance @@ -261,6 +291,20 @@ module Compliance end end + def self.target_is_automate2_server?(url, insecure) + automate_endpoint = '/dex/auth' + response = Compliance::HTTP.get(url + automate_endpoint, nil, insecure) + if response.code == '400' + Inspec::Log.debug( + "Received 400 from #{url}#{automate_endpoint} - " \ + 'assuming target is a Chef Automate2 instance', + ) + true + else + false + end + end + def self.target_is_automate_server?(url, insecure) automate_endpoint = '/compliance/version' response = Compliance::HTTP.get(url + automate_endpoint, nil, insecure) diff --git a/lib/bundles/inspec-compliance/api/login.rb b/lib/bundles/inspec-compliance/api/login.rb index 4de11a02b..e66d21cac 100644 --- a/lib/bundles/inspec-compliance/api/login.rb +++ b/lib/bundles/inspec-compliance/api/login.rb @@ -16,15 +16,56 @@ module Compliance options['server_type'] = Compliance::API.determine_server_type(options['server'], options['insecure']) case options['server_type'] + when :automate2 + Login::Automate2Server.login(options) when :automate - config = Login::AutomateServer.login(options) + Login::AutomateServer.login(options) when :compliance - config = Login::ComplianceServer.login(options) + Login::ComplianceServer.login(options) else raise CannotDetermineServerType, "Unable to determine if #{options['server']} is a Chef Automate or Chef Compliance server" end + end - puts "Stored configuration for Chef #{config['server_type'].capitalize}: #{config['server']}' with user: '#{config['user']}'" + module Automate2Server + def self.login(options) + verify_thor_options(options) + + options['url'] = options['server'] + '/api/v0' + token = options['dctoken'] || options['token'] + store_access_token(options, token) + end + + def self.store_access_token(options, token) + config = Compliance::Configuration.new + config.clean + + config['automate'] = {} + config['automate']['ent'] = 'automate' + config['automate']['token_type'] = 'dctoken' + config['server'] = options['url'] + config['user'] = options['user'] + config['owner'] = options['user'] + config['insecure'] = options['insecure'] || false + config['server_type'] = options['server_type'].to_s + config['token'] = token + config['version'] = '0' + + config.store + config + end + + def self.verify_thor_options(o) + error_msg = [] + + error_msg.push('Please specify a user using `--user=\'USER\'`') if o['user'].nil? + + if o['token'].nil? && o['dctoken'].nil? + error_msg.push('Please specify a token using `--token=\'APITOKEN\'`') + end + + raise ArgumentError, error_msg.join("\n") unless error_msg.empty? + end end module AutomateServer diff --git a/lib/bundles/inspec-compliance/cli.rb b/lib/bundles/inspec-compliance/cli.rb index 87d5c1396..8b8264985 100644 --- a/lib/bundles/inspec-compliance/cli.rb +++ b/lib/bundles/inspec-compliance/cli.rb @@ -44,6 +44,8 @@ module Compliance def login(server) options['server'] = server Compliance::API.login(options) + config = Compliance::Configuration.new + puts "Stored configuration for Chef #{config['server_type'].capitalize}: #{config['server']}' with user: '#{config['user']}'" end desc 'profiles', 'list all available profiles in Chef Compliance' @@ -62,7 +64,8 @@ module Compliance # iterate over profiles headline('Available profiles:') profiles.each { |profile| - li("#{profile['title']} v#{profile['version']} (#{mark_text(profile['owner_id'] + '/' + profile['name'])})") + owner = profile['owner_id'] || profile['owner'] + li("#{profile['title']} v#{profile['version']} (#{mark_text(owner + '/' + profile['name'])})") } else puts msg, 'Could not find any profiles' @@ -194,8 +197,11 @@ module Compliance puts "Start upload to #{config['owner']}/#{profile_name}" pname = ERB::Util.url_encode(profile_name) - Compliance::API.is_automate_server?(config) ? upload_msg = 'Uploading to Chef Automate' : upload_msg = 'Uploading to Chef Compliance' - puts upload_msg + if Compliance::API.is_automate_server?(config) || Compliance::API.is_automate2_server?(config) + puts 'Uploading to Chef Automate' + else + puts 'Uploading to Chef Compliance' + end success, msg = Compliance::API.upload(config, config['owner'], pname, archive_path) if success @@ -229,7 +235,7 @@ module Compliance unless config.supported?(:oidc) || config['token'].nil? || config['server_type'] == 'automate' config = Compliance::Configuration.new url = "#{config['server']}/logout" - Compliance::API.post(url, config['token'], config['insecure'], !config.supported?(:oidc)) + Compliance::HTTP.post(url, config['token'], config['insecure'], !config.supported?(:oidc)) end success = config.destroy diff --git a/lib/bundles/inspec-compliance/http.rb b/lib/bundles/inspec-compliance/http.rb index 449db62ce..7194c005e 100644 --- a/lib/bundles/inspec-compliance/http.rb +++ b/lib/bundles/inspec-compliance/http.rb @@ -33,6 +33,16 @@ module Compliance send_request(uri, req, insecure) end + def self.post_with_headers(url, headers, body, insecure) + uri = _parse_url(url) + req = Net::HTTP::Post.new(uri.path) + req.body = body unless body.nil? + headers&.each do |key, value| + req.add_field(key, value) + end + send_request(uri, req, insecure) + end + # post a file def self.post_file(url, headers, file_path, insecure) uri = _parse_url(url) @@ -58,6 +68,35 @@ module Compliance res end + def self.post_multipart_file(url, headers, file_path, insecure) + uri = _parse_url(url) + raise "Unable to parse URL: #{url}" if uri.nil? || uri.host.nil? + http = Net::HTTP.new(uri.host, uri.port) + + # set connection flags + http.use_ssl = (uri.scheme == 'https') + http.verify_mode = OpenSSL::SSL::VERIFY_NONE if insecure + + req = Net::HTTP::Post.new(uri) + headers.each do |key, value| + req.add_field(key, value) + end + + boundry = 'AaB03x' + req.add_field('Content-Type', "multipart/form-data; boundary=#{boundry}") + + post_body = [] + post_body << "--#{boundry}\r\n" + post_body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(file_path)}\"\r\n" + post_body << "Content-Type: application/x-gtar\r\n\r\n" + post_body << File.read(file_path) + post_body << "\r\n\r\n--#{boundry}--\r\n" + req.body = post_body.join + + res=http.request(req) + res + end + # sends a http requests def self.send_request(uri, req, insecure) opts = { diff --git a/lib/bundles/inspec-compliance/target.rb b/lib/bundles/inspec-compliance/target.rb index 1ce38fd89..2107b770c 100644 --- a/lib/bundles/inspec-compliance/target.rb +++ b/lib/bundles/inspec-compliance/target.rb @@ -13,7 +13,7 @@ module Compliance class Fetcher < Fetchers::Url name 'compliance' priority 500 - def self.resolve(target) # rubocop:disable PerceivedComplexity, Metrics/CyclomaticComplexity + def self.resolve(target) # rubocop:disable PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize uri = if target.is_a?(String) && URI(target).scheme == 'compliance' URI(target) elsif target.respond_to?(:key?) && target.key?(:compliance) @@ -33,6 +33,9 @@ module Compliance if config['server_type'] == 'automate' server = 'automate' msg = 'inspec compliance login https://your_automate_server --user USER --ent ENT --dctoken DCTOKEN or --token USERTOKEN' + elsif config['server_type'] == 'automate2' + server = 'automate2' + msg = 'inspec compliance login https://your_automate2_server --user USER --token APITOKEN' else server = 'compliance' msg = "inspec compliance login https://your_compliance_server --user admin --insecure --token 'PASTE TOKEN HERE' " @@ -57,6 +60,10 @@ module Compliance end # We need to pass the token to the fetcher config['token'] = Compliance::API.get_token(config) + + # Needed for automate2 post request + config['profile'] = Compliance::API.profile_split(profile) + new(profile_fetch_url, config) rescue URI::Error => _e nil diff --git a/lib/fetchers/url.rb b/lib/fetchers/url.rb index 84065eb35..15c037829 100644 --- a/lib/fetchers/url.rb +++ b/lib/fetchers/url.rb @@ -136,7 +136,44 @@ module Fetchers end def temp_archive_path - @temp_archive_path ||= download_archive_to_temp + @temp_archive_path ||= if @config['server_type'] == 'automate2' + download_automate2_archive_to_temp + else + download_archive_to_temp + end + end + + def download_automate2_archive_to_temp + return @temp_archive_path if !@temp_archive_path.nil? + + Inspec::Log.debug("Fetching URL: #{@target}") + json = { + owner: @config['profile'][0], + name: @config['profile'][1], + version: @config['profile'][2], + }.to_json + + uri = URI.parse(@target) + opts = http_opts + opts[:use_ssl] = uri.scheme == 'https' + + req = Net::HTTP::Post.new(uri) + opts.each do |key, value| + req.add_field(key, value) + end + req.body = json + res = Net::HTTP.start(uri.host, uri.port, opts) { |http| + http.request(req) + } + + @archive_type = '.tar.gz' + archive = Tempfile.new(['inspec-dl-', @archive_type]) + archive.binmode + archive.write(res.body) + archive.rewind + archive.close + Inspec::Log.debug("Archive stored at temporary location: #{archive.path}") + @temp_archive_path = archive.path end # Downloads archive to temporary file with side effect :( of setting @archive_type @@ -155,7 +192,7 @@ module Fetchers end def download_archive(path) - download_archive_to_temp + temp_archive_path final_path = "#{path}#{@archive_type}" FileUtils.mkdir_p(File.dirname(final_path)) FileUtils.mv(temp_archive_path, final_path) @@ -168,7 +205,7 @@ module Fetchers opts = {} opts[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if @insecure - if @config['server_type'] == 'automate' + if @config['server_type'] =~ /automate/ opts['chef-delivery-enterprise'] = @config['automate']['ent'] if @config['automate']['token_type'] == 'dctoken' opts['x-data-collector-token'] = @config['token'] diff --git a/lib/inspec/base_cli.rb b/lib/inspec/base_cli.rb index 076fd2343..309357fbc 100644 --- a/lib/inspec/base_cli.rb +++ b/lib/inspec/base_cli.rb @@ -240,6 +240,9 @@ module Inspec raise ArgumentError, "Please provide a value for --#{v}. For example: --#{v}=hello." end + # check for compliance settings + Compliance::API.login(o['compliance']) if o['compliance'] + o end diff --git a/test/unit/bundles/inspec-compliance/api/login_test.rb b/test/unit/bundles/inspec-compliance/api/login_test.rb index 7493e65f8..94b6b840e 100644 --- a/test/unit/bundles/inspec-compliance/api/login_test.rb +++ b/test/unit/bundles/inspec-compliance/api/login_test.rb @@ -47,6 +47,44 @@ describe Compliance::API do end describe '.login' do + describe 'when target is a Chef Automate2 server' do + before do + Compliance::API.expects(:determine_server_type).returns(:automate2) + end + + it 'raises an error if `--user` is missing' do + options = automate_options + options.delete('user') + err = proc { Compliance::API.login(options) }.must_raise(ArgumentError) + err.message.must_match(/Please specify a user.*/) + err.message.lines.length.must_equal(1) + end + + it 'raises an error if `--token` and `--dctoken` are missing' do + options = automate_options + options.delete('token') + options.delete('dctoken') + err = proc { Compliance::API.login(options) }.must_raise(ArgumentError) + err.message.must_match(/Please specify a token.*/) + err.message.lines.length.must_equal(1) + end + + it 'stores an access token' do + stub_request(:get, automate_options['server'] + '/compliance/version') + .to_return(status: 200, body: '', headers: {}) + options = automate_options + Compliance::Configuration.expects(:new).returns(fake_config) + + Compliance::API.login(options) + fake_config['automate']['ent'].must_equal('automate') + fake_config['automate']['token_type'].must_equal('dctoken') + fake_config['user'].must_equal('someone') + fake_config['server'].must_equal('https://automate.example.com/api/v0') + fake_config['server_type'].must_equal('automate2') + fake_config['token'].must_equal('token') + end + end + describe 'when target is a Chef Automate server' do before do Compliance::API.expects(:determine_server_type).returns(:automate) @@ -83,7 +121,7 @@ describe Compliance::API do options = automate_options Compliance::Configuration.expects(:new).returns(fake_config) - proc { Compliance::API.login(options) }.must_output(/Stored configuration.*Automate/) + Compliance::API.login(options) fake_config['automate']['ent'].must_equal('automate') fake_config['automate']['token_type'].must_equal('usertoken') fake_config['user'].must_equal('someone') @@ -123,7 +161,7 @@ describe Compliance::API do options = compliance_options Compliance::Configuration.expects(:new).returns(fake_config) - proc { Compliance::API.login(options) }.must_output(/Stored configuration.*Compliance/) + Compliance::API.login(options) fake_config['user'].must_equal('someone') fake_config['server'].must_equal('https://compliance.example.com/api') fake_config['server_type'].must_equal('compliance') diff --git a/test/unit/bundles/inspec-compliance/api_test.rb b/test/unit/bundles/inspec-compliance/api_test.rb index 5082749c4..f2d0626a8 100644 --- a/test/unit/bundles/inspec-compliance/api_test.rb +++ b/test/unit/bundles/inspec-compliance/api_test.rb @@ -122,6 +122,20 @@ describe Compliance::API do Compliance::API.is_automate_server?(config).must_equal false Compliance::API.is_automate_server_pre_080?(config).must_equal false Compliance::API.is_automate_server_080_and_later?(config).must_equal false + Compliance::API.is_automate2_server?(config).must_equal false + end + end + + describe 'when the config has a automate2 server_type' do + it 'automate/compliance server is? methods return correctly' do + config = Compliance::Configuration.new + config.clean + config['server_type'] = 'automate2' + Compliance::API.is_compliance_server?(config).must_equal false + Compliance::API.is_automate_server?(config).must_equal false + Compliance::API.is_automate_server_pre_080?(config).must_equal false + Compliance::API.is_automate_server_080_and_later?(config).must_equal false + Compliance::API.is_automate2_server?(config).must_equal true end end @@ -134,6 +148,7 @@ describe Compliance::API do Compliance::API.is_automate_server?(config).must_equal true Compliance::API.is_automate_server_pre_080?(config).must_equal true Compliance::API.is_automate_server_080_and_later?(config).must_equal false + Compliance::API.is_automate2_server?(config).must_equal false end end @@ -147,6 +162,7 @@ describe Compliance::API do Compliance::API.is_automate_server?(config).must_equal true Compliance::API.is_automate_server_pre_080?(config).must_equal true Compliance::API.is_automate_server_080_and_later?(config).must_equal false + Compliance::API.is_automate2_server?(config).must_equal false end end @@ -251,15 +267,30 @@ describe Compliance::API do let(:compliance_endpoint) { '/api/version' } let(:automate_endpoint) { '/compliance/version' } + let(:automate2_endpoint) { '/dex/auth' } let(:headers) { nil } let(:insecure) { true } let(:good_response) { mock } let(:bad_response) { mock } + it 'returns `:automate2` when a 400 is received from `https://URL/dex/auth`' do + good_response.stubs(:code).returns('400') + + Compliance::HTTP.expects(:get) + .with(url + automate2_endpoint, headers, insecure) + .returns(good_response) + + Compliance::API.determine_server_type(url, insecure).must_equal(:automate2) + end + it 'returns `:automate` when a 401 is received from `https://URL/compliance/version`' do good_response.stubs(:code).returns('401') + bad_response.stubs(:code).returns('404') + Compliance::HTTP.expects(:get) + .with(url + automate2_endpoint, headers, insecure) + .returns(bad_response) Compliance::HTTP.expects(:get) .with(url + automate_endpoint, headers, insecure) .returns(good_response) @@ -271,9 +302,13 @@ describe Compliance::API do # versions of OpsWorks Chef Automate return 200 and a Chef Manage page when # unauthenticated requests are received. it 'returns `:automate` when a 200 is received from `https://URL/compliance/version`' do + bad_response.stubs(:code).returns('404') good_response.stubs(:code).returns('200') good_response.stubs(:body).returns('Are You Looking For the Chef Server?') + Compliance::HTTP.expects(:get) + .with(url + automate2_endpoint, headers, insecure) + .returns(bad_response) Compliance::HTTP.expects(:get) .with(url + automate_endpoint, headers, insecure) .returns(good_response) @@ -288,6 +323,9 @@ describe Compliance::API do Compliance::HTTP.expects(:get) .with(url + automate_endpoint, headers, insecure) .returns(bad_response) + Compliance::HTTP.expects(:get) + .with(url + automate2_endpoint, headers, insecure) + .returns(bad_response) mock_compliance_response = mock mock_compliance_response.stubs(:code).returns('404') @@ -306,6 +344,9 @@ describe Compliance::API do Compliance::HTTP.expects(:get) .with(url + automate_endpoint, headers, insecure) .returns(bad_response) + Compliance::HTTP.expects(:get) + .with(url + automate2_endpoint, headers, insecure) + .returns(bad_response) Compliance::HTTP.expects(:get) .with(url + compliance_endpoint, headers, insecure) .returns(good_response) @@ -316,6 +357,9 @@ describe Compliance::API do it 'returns `nil` if it cannot determine the server type' do bad_response.stubs(:code).returns('404') + Compliance::HTTP.expects(:get) + .with(url + automate2_endpoint, headers, insecure) + .returns(bad_response) Compliance::HTTP.expects(:get) .with(url + automate_endpoint, headers, insecure) .returns(bad_response)