mirror of
https://github.com/inspec/inspec
synced 2024-11-26 22:50:36 +00:00
http resource: properly execute tests on remote target (#2209)
Currently, the http resource always executes locally, even when scanning a remote machine with `--target` which leads to undesireable behavior. This change adds the ability to remotely execute tests with curl. This behavior is currently opt-in with the `enable_remote_worker` flag, but will become the default behavior in InSpec 2.0. Deprecation warnings are emitted if the user is scanning a remote target but has not opted in to the new behavior. Signed-off-by: Adam Leff <adam@leff.co>
This commit is contained in:
parent
4f091da9fa
commit
21ba43d6a5
9 changed files with 358 additions and 74 deletions
|
@ -6,9 +6,12 @@ title: About the http Resource
|
|||
|
||||
Use the `http` InSpec audit resource to test an http endpoint.
|
||||
|
||||
<p class="warning">Currently, this resource always executes on the host on which <code>inspec exec</code> is run, even if you use the <code>--target</code> option to remotely scan a different host.<br>
|
||||
<p class="warning">In InSpec 1.40 and earlier, this resource always executes on the host on which <code>inspec exec</code> is run, even if you use the <code>--target</code> option to remotely scan a different host.<br>
|
||||
<br>
|
||||
This will be corrected in a future version of InSpec. New InSpec releases are posted in the <a href="https://discourse.chef.io/c/chef-release" target="_blank">Release Announcements Category in Discourse</a>.</p>
|
||||
Beginning with InSpec 1.41, you can enable the ability to have the HTTP test execute on the remote target, provided <code>curl</code> is available. See the "Local vs. Remote" section below.<br>
|
||||
<br>
|
||||
Executing the HTTP test on the remote target will be the default behavior in InSpec 2.0.
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
|
@ -35,6 +38,15 @@ where
|
|||
* `ssl_verify` may be specified to enable or disable verification of SSL certificates (default to `true`)
|
||||
|
||||
<br>
|
||||
## Local vs. Remote
|
||||
|
||||
Beginning with InSpec 1.41, you can enable the ability to have the HTTP test execute on the remote target:
|
||||
|
||||
describe http('http://www.example.com', enable_remote_worker: true) do
|
||||
its('body') { should cmp 'awesome' }
|
||||
end
|
||||
|
||||
In InSpec 2.0, the HTTP test will automatically execute remotely whenever InSpec is testing a remote node.
|
||||
|
||||
## Examples
|
||||
|
||||
|
@ -63,10 +75,6 @@ For example, a service is listening on default http port can be tested like this
|
|||
|
||||
<br>
|
||||
|
||||
## Matchers
|
||||
|
||||
This InSpec audit resource has the following matchers. For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/).
|
||||
|
||||
### body
|
||||
|
||||
The `body` matcher tests body content of http response:
|
||||
|
|
|
@ -17,6 +17,14 @@ module Inspec
|
|||
Inspec::VERSION
|
||||
end
|
||||
|
||||
# Determine whether the connection/transport is a local connection
|
||||
# Useful for resources to modify behavior as necessary, such as using
|
||||
# the Ruby stdlib for a better experience.
|
||||
def local_transport?
|
||||
return false unless defined?(Train::Transports::Local)
|
||||
backend.is_a?(Train::Transports::Local::Connection)
|
||||
end
|
||||
|
||||
# Ruby internal for printing a nice name for this class
|
||||
def to_s
|
||||
'Inspec::Backend::Class'
|
||||
|
|
|
@ -22,51 +22,201 @@ module Inspec::Resources
|
|||
its('Content-Length') { should cmp 258 }
|
||||
its('Content-Type') { should cmp 'text/html; charset=UTF-8' }
|
||||
end
|
||||
|
||||
# properly execute the HTTP call on the scanned machine instead of the
|
||||
# machine executing InSpec. This will be the default behavior in InSpec 2.0.
|
||||
describe http('http://localhost:8080', enable_remote_worker: true) do
|
||||
its('body') { should cmp 'local web server on target machine' }
|
||||
end
|
||||
"
|
||||
|
||||
def initialize(url, opts = {})
|
||||
@url = url
|
||||
@method = opts.fetch(:method, 'GET')
|
||||
@params = opts.fetch(:params, nil)
|
||||
@auth = opts.fetch(:auth, {})
|
||||
@headers = opts.fetch(:headers, {})
|
||||
@data = opts.fetch(:data, nil)
|
||||
@open_timeout = opts.fetch(:open_timeout, 60)
|
||||
@read_timeout = opts.fetch(:read_timeout, 60)
|
||||
@ssl_verify = opts.fetch(:ssl_verify, true)
|
||||
@opts = opts
|
||||
|
||||
if use_remote_worker?
|
||||
return skip_resource 'curl is not available on the target machine' unless inspec.command('curl').exist?
|
||||
@worker = Worker::Remote.new(inspec, http_method, url, opts)
|
||||
else
|
||||
@worker = Worker::Local.new(http_method, url, opts)
|
||||
end
|
||||
end
|
||||
|
||||
def status
|
||||
response.status
|
||||
end
|
||||
|
||||
def body
|
||||
response.body
|
||||
@worker.status
|
||||
end
|
||||
|
||||
def headers
|
||||
Hashie::Mash.new(response.headers.to_h)
|
||||
Hashie::Mash.new(@worker.response_headers)
|
||||
end
|
||||
|
||||
def body
|
||||
@worker.body
|
||||
end
|
||||
|
||||
def http_method
|
||||
@opts.fetch(:method, 'GET')
|
||||
end
|
||||
|
||||
def to_s
|
||||
"http #{@method} on #{@url}"
|
||||
"http #{http_method} on #{@url}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def response
|
||||
return @response if @response
|
||||
conn = Faraday.new url: @url, headers: @headers, params: @params, ssl: { verify: @ssl_verify }
|
||||
def use_remote_worker?
|
||||
return false if inspec.local_transport?
|
||||
return true if @opts[:enable_remote_worker]
|
||||
|
||||
# set basic authentication
|
||||
conn.basic_auth @auth[:user], @auth[:pass] unless @auth.empty?
|
||||
warn "[DEPRECATION] #{self} will execute locally instead of the target machine. To execute remotely, add `enable_remote_worker: true`."
|
||||
warn '[DEPRECATION] `enable_remote_worker: true` will be the default behavior in InSpec 2.0.'
|
||||
false
|
||||
end
|
||||
|
||||
# set default timeout
|
||||
conn.options.timeout = @read_timeout # open/read timeout in seconds
|
||||
conn.options.open_timeout = @open_timeout # connection open timeout in seconds
|
||||
class Worker
|
||||
class Base
|
||||
attr_reader :http_method, :opts, :url
|
||||
|
||||
@response = conn.send(@method.downcase) do |req|
|
||||
req.body = @data
|
||||
def initialize(http_method, url, opts)
|
||||
@http_method = http_method
|
||||
@url = url
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def params
|
||||
opts.fetch(:params, nil)
|
||||
end
|
||||
|
||||
def username
|
||||
opts.fetch(:auth, {})[:user]
|
||||
end
|
||||
|
||||
def password
|
||||
opts.fetch(:auth, {})[:pass]
|
||||
end
|
||||
|
||||
def request_headers
|
||||
opts.fetch(:headers, {})
|
||||
end
|
||||
|
||||
def request_body
|
||||
opts[:data]
|
||||
end
|
||||
|
||||
def open_timeout
|
||||
opts.fetch(:open_timeout, 60)
|
||||
end
|
||||
|
||||
def read_timeout
|
||||
opts.fetch(:read_timeout, 60)
|
||||
end
|
||||
|
||||
def ssl_verify?
|
||||
opts.fetch(:ssl_verify, true)
|
||||
end
|
||||
end
|
||||
|
||||
class Local < Base
|
||||
def status
|
||||
response.status
|
||||
end
|
||||
|
||||
def body
|
||||
response.body
|
||||
end
|
||||
|
||||
def response_headers
|
||||
response.headers.to_h
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def response
|
||||
return @response if @response
|
||||
conn = Faraday.new url: url, headers: request_headers, params: params, ssl: { verify: ssl_verify? }
|
||||
|
||||
# set basic authentication
|
||||
conn.basic_auth username, password unless username.nil? || password.nil?
|
||||
|
||||
# set default timeout
|
||||
conn.options.timeout = read_timeout # open/read timeout in seconds
|
||||
conn.options.open_timeout = open_timeout # connection open timeout in seconds
|
||||
|
||||
@response = conn.send(http_method.downcase) do |req|
|
||||
req.body = request_body
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Remote < Base
|
||||
attr_reader :inspec
|
||||
|
||||
def initialize(inspec, http_method, url, opts)
|
||||
@inspec = inspec
|
||||
super(http_method, url, opts)
|
||||
end
|
||||
|
||||
def status
|
||||
run_curl
|
||||
@status
|
||||
end
|
||||
|
||||
def body
|
||||
run_curl
|
||||
@body
|
||||
end
|
||||
|
||||
def response_headers
|
||||
run_curl
|
||||
@response_headers
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run_curl
|
||||
return if @ran_curl
|
||||
|
||||
response = inspec.command(curl_command).stdout
|
||||
@ran_curl = true
|
||||
return if response.nil?
|
||||
|
||||
# strip any carriage returns to normalize output
|
||||
response.delete!("\r")
|
||||
|
||||
# split the prelude (status line and headers) and the body
|
||||
prelude, @body = response.split("\n\n", 2)
|
||||
prelude = prelude.lines
|
||||
|
||||
# grab the status off of the first line of the prelude
|
||||
status_line = prelude.shift
|
||||
@status = status_line.split(' ', 3)[1].to_i
|
||||
|
||||
# parse the rest of the prelude which will be all the HTTP headers
|
||||
@response_headers = {}
|
||||
prelude.each do |line|
|
||||
line.strip!
|
||||
key, value = line.split(':', 2)
|
||||
@response_headers[key] = value.strip
|
||||
end
|
||||
end
|
||||
|
||||
def curl_command
|
||||
cmd = ["curl -i -X #{http_method}"]
|
||||
cmd << "--connect-timeout #{open_timeout}"
|
||||
cmd << "--user \'#{username}:#{password}\'" unless username.nil? || password.nil?
|
||||
cmd << '--insecure' unless ssl_verify?
|
||||
cmd << "--data #{Shellwords.shellescape(request_body)}" unless request_body.nil?
|
||||
|
||||
request_headers.each do |k, v|
|
||||
cmd << "-H '#{k}=#{v}'"
|
||||
end
|
||||
|
||||
cmd << "'#{url}'"
|
||||
|
||||
cmd.join(' ')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -417,6 +417,12 @@ class MockLoader
|
|||
'/usr/sbin/service sshd status' => empty.call,
|
||||
'/sbin/service sshd status' => empty.call,
|
||||
'type "lsof"' => empty.call,
|
||||
|
||||
# http resource - remote worker'
|
||||
"curl -i -X GET --connect-timeout 60 'http://www.example.com'" => cmd.call('http-remote-no-options'),
|
||||
"curl -i -X GET --connect-timeout 60 --user 'user:pass' 'http://www.example.com'" => cmd.call('http-remote-basic-auth'),
|
||||
'2bdc8826b66efa554bdebd8cc5f3eaf7bfba5ada36adc7904a6b178d331395ea' => cmd.call('http-remote-post'),
|
||||
"curl -i -X GET --connect-timeout 60 -H 'accept=application/json' -H 'foo=bar' 'http://www.example.com'" => cmd.call('http-remote-headers'),
|
||||
}
|
||||
@backend
|
||||
end
|
||||
|
|
15
test/unit/mock/cmd/http-remote-basic-auth
Normal file
15
test/unit/mock/cmd/http-remote-basic-auth
Normal file
|
@ -0,0 +1,15 @@
|
|||
HTTP/1.1 200 OK
|
||||
Date: Tue, 03 Oct 2017 20:30:08 GMT
|
||||
Expires: -1
|
||||
Cache-Control: private, max-age=0
|
||||
Content-Type: text/html; charset=ISO-8859-1
|
||||
P3P: CP="This is not a P3P policy! See https://www.google.com/support/accounts/answer/151657?hl=en for more info."
|
||||
Server: gws
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
Set-Cookie: NID=113=kNR6MIUK3vNjVH3KvQqIjfnDLLPHJ96wmC_z643weEFQ6Cfq0B2iUYqxzQk5pKVoAkbL8ZxKFvvM9v55qiNFXH_O655WuuUxPshmlSIM5xpCSH0xy09SnIQJVi0l7eKY; expires=Wed, 04-Apr-2018 20:30:08 GMT; path=/; domain=.google.com; HttpOnly
|
||||
Accept-Ranges: none
|
||||
Vary: Accept-Encoding
|
||||
Transfer-Encoding: chunked
|
||||
|
||||
auth ok
|
16
test/unit/mock/cmd/http-remote-headers
Normal file
16
test/unit/mock/cmd/http-remote-headers
Normal file
|
@ -0,0 +1,16 @@
|
|||
HTTP/1.1 200 OK
|
||||
Date: Tue, 03 Oct 2017 20:30:08 GMT
|
||||
Expires: -1
|
||||
Cache-Control: private, max-age=0
|
||||
Content-Type: text/html; charset=ISO-8859-1
|
||||
P3P: CP="This is not a P3P policy! See https://www.google.com/support/accounts/answer/151657?hl=en for more info."
|
||||
Server: gws
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
Set-Cookie: NID=113=kNR6MIUK3vNjVH3KvQqIjfnDLLPHJ96wmC_z643weEFQ6Cfq0B2iUYqxzQk5pKVoAkbL8ZxKFvvM9v55qiNFXH_O655WuuUxPshmlSIM5xpCSH0xy09SnIQJVi0l7eKY; expires=Wed, 04-Apr-2018 20:30:08 GMT; path=/; domain=.google.com; HttpOnly
|
||||
Accept-Ranges: none
|
||||
Vary: Accept-Encoding
|
||||
Transfer-Encoding: chunked
|
||||
mock: ok
|
||||
|
||||
headers ok
|
15
test/unit/mock/cmd/http-remote-no-options
Normal file
15
test/unit/mock/cmd/http-remote-no-options
Normal file
|
@ -0,0 +1,15 @@
|
|||
HTTP/1.1 200 OK
|
||||
Date: Tue, 03 Oct 2017 20:30:08 GMT
|
||||
Expires: -1
|
||||
Cache-Control: private, max-age=0
|
||||
Content-Type: text/html; charset=ISO-8859-1
|
||||
P3P: CP="This is not a P3P policy! See https://www.google.com/support/accounts/answer/151657?hl=en for more info."
|
||||
Server: gws
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
Set-Cookie: NID=113=kNR6MIUK3vNjVH3KvQqIjfnDLLPHJ96wmC_z643weEFQ6Cfq0B2iUYqxzQk5pKVoAkbL8ZxKFvvM9v55qiNFXH_O655WuuUxPshmlSIM5xpCSH0xy09SnIQJVi0l7eKY; expires=Wed, 04-Apr-2018 20:30:08 GMT; path=/; domain=.google.com; HttpOnly
|
||||
Accept-Ranges: none
|
||||
Vary: Accept-Encoding
|
||||
Transfer-Encoding: chunked
|
||||
|
||||
no options
|
15
test/unit/mock/cmd/http-remote-post
Normal file
15
test/unit/mock/cmd/http-remote-post
Normal file
|
@ -0,0 +1,15 @@
|
|||
HTTP/1.1 200 OK
|
||||
Date: Tue, 03 Oct 2017 20:30:08 GMT
|
||||
Expires: -1
|
||||
Cache-Control: private, max-age=0
|
||||
Content-Type: text/html; charset=ISO-8859-1
|
||||
P3P: CP="This is not a P3P policy! See https://www.google.com/support/accounts/answer/151657?hl=en for more info."
|
||||
Server: gws
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
Set-Cookie: NID=113=kNR6MIUK3vNjVH3KvQqIjfnDLLPHJ96wmC_z643weEFQ6Cfq0B2iUYqxzQk5pKVoAkbL8ZxKFvvM9v55qiNFXH_O655WuuUxPshmlSIM5xpCSH0xy09SnIQJVi0l7eKY; expires=Wed, 04-Apr-2018 20:30:08 GMT; path=/; domain=.google.com; HttpOnly
|
||||
Accept-Ranges: none
|
||||
Vary: Accept-Encoding
|
||||
Transfer-Encoding: chunked
|
||||
|
||||
post ok
|
|
@ -5,58 +5,109 @@ require 'helper'
|
|||
require 'inspec/resource'
|
||||
|
||||
describe 'Inspec::Resources::Http' do
|
||||
it 'verify simple http' do
|
||||
stub_request(:get, "www.example.com").to_return(status: 200, body: 'pong')
|
||||
describe 'InSpec::Resources::Http::Worker::Local' do
|
||||
let(:domain) { 'www.example.com' }
|
||||
let(:http_method) { 'GET' }
|
||||
let(:opts) { {} }
|
||||
let(:worker) { Inspec::Resources::Http::Worker::Local.new(http_method, "http://#{domain}", opts) }
|
||||
|
||||
resource = load_resource('http', 'http://www.example.com')
|
||||
_(resource.status).must_equal 200
|
||||
_(resource.body).must_equal 'pong'
|
||||
describe 'simple HTTP request with no options' do
|
||||
it 'returns correct data' do
|
||||
stub_request(:get, domain).to_return(status: 200, body: 'pong')
|
||||
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'pong'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'request with basic auth' do
|
||||
let(:opts) { { auth: { user: 'user', pass: 'pass' } } }
|
||||
|
||||
it 'returns correct data' do
|
||||
stub_request(:get, domain).with(basic_auth: ['user', 'pass']).to_return(status: 200, body: 'auth ok')
|
||||
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'auth ok'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST request with data' do
|
||||
let(:http_method) { 'POST'}
|
||||
let(:opts) { { data: {a: '1', b: 'five'} } }
|
||||
|
||||
it 'returns correct data' do
|
||||
stub_request(:post, domain).with(body: {a: '1', b: 'five'}).to_return(status: 200, body: 'post ok')
|
||||
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'post ok'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'with request headers' do
|
||||
let(:opts) { { headers: { 'accept' => 'application/json' } } }
|
||||
|
||||
it 'returns correct data' do
|
||||
stub_request(:get, domain).with(headers: {'accept' => 'application/json'}).to_return(status: 200, body: 'headers ok', headers: {'mock' => 'ok'})
|
||||
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'headers ok'
|
||||
_(worker.response_headers['mock']).must_equal 'ok'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'with params' do
|
||||
let(:opts) { { params: { a: 'b' } } }
|
||||
|
||||
it 'returns correct data' do
|
||||
stub_request(:get, domain).with(query: {a: 'b'}).to_return(status: 200, body: 'params ok')
|
||||
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'params ok'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'verify http with basic auth' do
|
||||
stub_request(:get, "www.example.com").with(basic_auth: ['user', 'pass']).to_return(status: 200, body: 'auth ok')
|
||||
describe 'Inspec::Resource::Http::Worker::Remote' do
|
||||
let(:backend) { MockLoader.new.backend }
|
||||
let(:http_method) { 'GET' }
|
||||
let(:url) { 'http://www.example.com' }
|
||||
let(:opts) { {} }
|
||||
let(:worker) { Inspec::Resources::Http::Worker::Remote.new(backend, http_method, url, opts)}
|
||||
|
||||
resource = load_resource('http', 'http://www.example.com', auth: { user: 'user',pass: 'pass'})
|
||||
_(resource.status).must_equal 200
|
||||
_(resource.body).must_equal 'auth ok'
|
||||
end
|
||||
describe 'simple HTTP request with no options' do
|
||||
it 'returns correct data' do
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'no options'
|
||||
end
|
||||
end
|
||||
|
||||
it 'verify http post with data' do
|
||||
stub_request(:post, "www.example.com").with(body: {data: {a: '1', b: 'five'}}).to_return(status: 200, body: 'post ok')
|
||||
describe 'request with basic auth' do
|
||||
let(:opts) { { auth: { user: 'user', pass: 'pass' } } }
|
||||
|
||||
resource = load_resource('http', 'http://www.example.com',
|
||||
method: 'POST',
|
||||
data: '{"data":{"a":"1","b":"five"}}',
|
||||
headers: {'content-type' => 'application/json'})
|
||||
_(resource.status).must_equal 200
|
||||
_(resource.body).must_equal 'post ok'
|
||||
end
|
||||
it 'returns correct data' do
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'auth ok'
|
||||
end
|
||||
end
|
||||
|
||||
it 'verify http headers' do
|
||||
stub_request(:post, "www.example.com").with(headers: {'content-type' => 'application/json'}).to_return(status: 200, body: 'headers ok', headers: {'mock' => 'ok'})
|
||||
describe 'POST request with data' do
|
||||
let(:http_method) { 'POST'}
|
||||
let(:opts) { { data: {a: '1', b: 'five'} } }
|
||||
|
||||
resource = load_resource('http', 'http://www.example.com',
|
||||
method: 'POST',
|
||||
data: '{"data":{"a":"1","b":"five"}}',
|
||||
headers: {'content-type' => 'application/json'})
|
||||
_(resource.status).must_equal 200
|
||||
_(resource.body).must_equal 'headers ok'
|
||||
_(resource.headers.mock).must_equal 'ok'
|
||||
end
|
||||
it 'returns correct data' do
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'post ok'
|
||||
end
|
||||
end
|
||||
|
||||
it 'verify http with params' do
|
||||
stub_request(:get, "www.example.com").with(query: {a: 'b'}).to_return(status: 200, body: 'params ok')
|
||||
describe 'with request headers' do
|
||||
let(:opts) { { headers: { 'accept' => 'application/json', 'foo' => 'bar' } } }
|
||||
|
||||
resource = load_resource('http', 'http://www.example.com', params: {a: 'b'})
|
||||
_(resource.status).must_equal 200
|
||||
_(resource.body).must_equal 'params ok'
|
||||
end
|
||||
|
||||
it 'verify http with timeouts' do
|
||||
stub_request(:get, "www.example.com").to_return(status: 200, body: 'params ok')
|
||||
|
||||
resource = load_resource('http', 'http://www.example.com', open_timeout: 10, read_timeout: 10)
|
||||
_(resource.instance_variable_get(:@open_timeout)).must_equal 10
|
||||
_(resource.instance_variable_get(:@read_timeout)).must_equal 10
|
||||
it 'returns correct data' do
|
||||
_(worker.status).must_equal 200
|
||||
_(worker.body).must_equal 'headers ok'
|
||||
_(worker.response_headers['mock']).must_equal 'ok'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue