Add A2 support to the inspec-compliance toolset (#2963)

* Add A2 support to the inspec-compliance toolset.
* Add json-config option for login.

Signed-off-by: Jared Quick <jquick@chef.io>
This commit is contained in:
Jared Quick 2018-04-19 13:01:54 -04:00 committed by GitHub
parent 340b6eb4b4
commit 33fc15582a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 286 additions and 19 deletions

View file

@ -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).

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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 = {

View file

@ -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

View file

@ -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']

View file

@ -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

View file

@ -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')

View file

@ -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)