processes resource: support busybox ps (#2222)

This change enhances the processes resource to support the busybox
ps command which is common on Alpine, for example. The way we
map ps fields to the structs needed by FilterTable have also been
refactored to be more flexible so we can support multiple formats
in the future.

Also, the processes resource now allows the grep argument to be optional
thus allowing a user to query all resources without passing in a
match-all regex.

Signed-off-by: Adam Leff <adam@leff.co>
This commit is contained in:
Adam Leff 2017-10-06 13:32:39 -04:00 committed by Dominik Richter
parent 999d115fb8
commit 939ee5ecfc
5 changed files with 125 additions and 21 deletions

View file

@ -18,7 +18,7 @@ A `processes` resource block declares the name of the process to be tested, and
where
* `processes('process_name')` specifies the name of a process to check. If this is a string, it will be converted to a Regexp. For more specificity, pass a Regexp directly.
* `processes('process_name')` specifies the name of a process to check. If this is a string, it will be converted to a Regexp. For more specificity, pass a Regexp directly. If left blank, all processes will be returned.
* `property_name` may be used to test user (`its('users')`) and state properties (`its('states')`)
<br>

View file

@ -4,9 +4,10 @@
# author: Christoph Hartmann
require 'utils/filter'
require 'ostruct'
module Inspec::Resources
class Processes < Inspec.resource(1)
class Processes < Inspec.resource(1) # rubocop:disable Metrics/ClassLength
name 'processes'
desc 'Use the processes InSpec audit resource to test properties for programs that are running on the system.'
example "
@ -15,12 +16,18 @@ module Inspec::Resources
its('users') { should eq ['mysql'] }
its('states') { should include 'S' }
end
describe processes(/.+/).where { label != 'unconfined' && pid < 1000 } do
its('users') { should cmp [] }
end
# work with all processes
describe processes do
its('entries.length') { should be <= 100 }
end
"
def initialize(grep)
def initialize(grep = /.*/)
@grep = grep
# turn into a regexp if it isn't one yet
if grep.class == String
@ -80,39 +87,118 @@ module Inspec::Resources
os = inspec.os
if os.linux?
command = 'ps axo label,pid,pcpu,pmem,vsz,rss,tty,stat,start,time,user:32,command'
regex = /^(.+?)\s+(\d+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+(\w{3} \d{2}|\d{2}:\d{2}:\d{2})\s+([^ ]+)\s+([^ ]+)\s+(.*)$/
command, regex, field_map = ps_configuration_for_linux
elsif os.windows?
command = '$Proc = Get-Process -IncludeUserName | Where-Object {$_.Path -ne $null } | Select-Object PriorityClass,Id,CPU,PM,VirtualMemorySize,NPM,SessionId,Responding,StartTime,TotalProcessorTime,UserName,Path | ConvertTo-Csv -NoTypeInformation;$Proc.Replace("""","").Replace("`r`n","`n")'
# Wanted to use /(?:^|,)([^,]*)/; works on rubular.com not sure why here?
regex = /^(.+),(.+),(.+),(.+),(.+),(.+),(.+),(.+),(.+),(.+),(.+),(.+)$/
field_map = {
pid: 2,
cpu: 3,
mem: 4,
vsz: 5,
rss: 6,
tty: 7,
stat: 8,
start: 9,
time: 10,
user: 11,
command: 12,
}
else
command = 'ps axo pid,pcpu,pmem,vsz,rss,tty,stat,start,time,user,command'
regex = /^\s*([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+(.*)$/
field_map = {
pid: 1,
cpu: 2,
mem: 3,
vsz: 4,
rss: 5,
tty: 6,
stat: 7,
start: 8,
time: 9,
user: 10,
command: 11,
}
end
build_process_list(command, regex, os)
build_process_list(command, regex, field_map)
end
Process = Struct.new(:label, :pid,
:cpu, :mem, :vsz,
:rss, :tty, :stat,
:start, :time, :user, :command)
def ps_configuration_for_linux
if busybox_ps?
command = 'ps -o pid,vsz,rss,tty,stat,time,ruser,args'
regex = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/
field_map = {
pid: 1,
vsz: 2,
rss: 3,
tty: 4,
stat: 5,
time: 6,
user: 7,
command: 8,
}
else
command = 'ps axo label,pid,pcpu,pmem,vsz,rss,tty,stat,start,time,user:32,command'
regex = /^(.+?)\s+(\d+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+([^ ]+)\s+(\w{3} \d{2}|\d{2}:\d{2}:\d{2})\s+([^ ]+)\s+([^ ]+)\s+(.*)$/
field_map = {
label: 1,
pid: 2,
cpu: 3,
mem: 4,
vsz: 5,
rss: 6,
tty: 7,
stat: 8,
start: 9,
time: 10,
user: 11,
command: 12,
}
end
def build_process_list(command, regex, os)
[command, regex, field_map]
end
def busybox_ps?
@busybox_ps ||= inspec.command('ps --help').stderr.include?('BusyBox')
end
def build_process_list(command, regex, field_map)
cmd = inspec.command(command)
all = cmd.stdout.split("\n")[1..-1]
return [] if all.nil?
lines = all.map do |line|
line.match(regex)
# map all the process lines into match objects, fetch the available fields,
# and then build an OpenStruct of the process data for each process
all.map do |line|
line = line.match(regex)
# skip this line if we couldn't match the regular expression
next if line.nil?
# skip this entry if there's no command for this line
next if line[field_map[:command]].nil?
# build a hash of process data that we'll turn into a struct for FilterTable
process_data = {}
[:label, :pid, :cpu, :mem, :vsz, :rss, :tty, :stat, :start, :time, :user, :command].each do |param|
# not all operating systems support all fields, so skip the field if we don't have it
process_data[param] = line[field_map[param]] if field_map.key?(param)
end
# ensure pid, vsz, and rss are integers for backward compatibility
[:pid, :vsz, :rss].each do |int_param|
process_data[int_param] = process_data[int_param].to_i if process_data.key?(int_param)
end
# strip any newlines off the command
process_data[:command].strip!
# return an OpenStruct of the process for future use by FilterTable
OpenStruct.new(process_data)
end.compact
lines.map do |m|
a = m.to_a[1..-1] # grab all matching groups
a.unshift(nil) unless os.linux? || os.windows?
a[1] = a[1].to_i
a[4] = a[4].to_i
a[5] = a[5].to_i
Process.new(*a)
end
end
end
end

View file

@ -43,6 +43,7 @@ Inspec::Log.logger = Logger.new(nil)
class MockLoader
# collects emulation operating systems
OPERATING_SYSTEMS = {
alpine: { name: 'alpine', family: 'alpine', release: '3.6.2', arch: 'x86_64' },
arch: { name: 'arch', family: 'arch', release: nil, arch: nil },
centos5: { name: 'centos', family: 'redhat', release: '5.11', arch: 'x86_64' },
centos6: { name: 'centos', family: 'redhat', release: '6.6', arch: 'x86_64' },
@ -194,6 +195,8 @@ class MockLoader
mock.commands = {
'ps axo pid,pcpu,pmem,vsz,rss,tty,stat,start,time,user,command' => cmd.call('ps-axo'),
'ps axo label,pid,pcpu,pmem,vsz,rss,tty,stat,start,time,user:32,command' => cmd.call('ps-axoZ'),
'ps -o pid,vsz,rss,tty,stat,time,ruser,args' => cmd.call('ps-busybox'),
'ps --help' => empty.call,
'Get-Content win_secpol.cfg' => cmd.call('secedit-export'),
'secedit /export /cfg win_secpol.cfg' => cmd.call('success'),
'Remove-Item win_secpol.cfg' => cmd.call('success'),

View file

@ -0,0 +1,3 @@
PID VSZ RSS TT STAT TIME RUSER COMMAND
1 1536 4 136,0 S 0:00 root /bin/sh
5 1528 4 136,0 R 0:00 joe /some/other/coolprogram

View file

@ -160,4 +160,16 @@ describe 'Inspec::Resources::Processes' do
resource = MockLoader.new(:windows).load_resource('processes', 'unicorn.exe')
_(resource.exists?).must_equal false
end
it 'returns the correct command for busybox ps' do
resource = MockLoader.new(:alpine).load_resource('processes')
resource.expects(:busybox_ps?).returns(true)
resource.send(:ps_configuration_for_linux)[0].must_equal 'ps -o pid,vsz,rss,tty,stat,time,ruser,args'
end
it 'returns the correct command for non-busybox linux' do
resource = MockLoader.new(:centos7).load_resource('processes')
resource.expects(:busybox_ps?).returns(false)
resource.send(:ps_configuration_for_linux)[0].must_equal 'ps axo label,pid,pcpu,pmem,vsz,rss,tty,stat,start,time,user:32,command'
end
end