require "inspec/resources/directory" require "inspec/utils/simpleconfig" # Resource to determine package information # # Usage: # describe package('nginx') do # it { should be_installed } # end module Inspec::Resources class Package < Inspec.resource(1) name "package" supports platform: "unix" supports platform: "windows" desc "Use the package InSpec audit resource to test if the named package and/or package version is installed on the system." example <<~EXAMPLE describe package('nginx') do it { should be_installed } it { should_not be_held } # for dpkg platforms that support holding a version from being upgraded its('version') { should eq 1.9.5 } end EXAMPLE def initialize(package_name, opts = {}) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity @package_name = package_name @name = @package_name @cache = nil # select package manager @pkgman = nil os = inspec.os if os.debian? @pkgman = Deb.new(inspec) elsif os.redhat? || %w{suse amazon fedora}.include?(os[:family]) @pkgman = Rpm.new(inspec, opts) elsif ["arch"].include?(os[:name]) @pkgman = Pacman.new(inspec) elsif ["darwin"].include?(os[:family]) @pkgman = Brew.new(inspec) elsif os.windows? @pkgman = WindowsPkg.new(inspec) elsif ["aix"].include?(os[:family]) @pkgman = BffPkg.new(inspec) elsif os.solaris? @pkgman = SolarisPkg.new(inspec) elsif ["hpux"].include?(os[:family]) @pkgman = HpuxPkg.new(inspec) elsif ["alpine"].include?(os[:name]) @pkgman = AlpinePkg.new(inspec) elsif ["freebsd"].include?(os[:name]) @pkgman = FreebsdPkg.new(inspec) else raise Inspec::Exceptions::ResourceSkipped, "The `package` resource is not supported on your OS yet." end evaluate_missing_requirements end # returns true if the package is installed def installed?(_provider = nil, _version = nil) info[:installed] == true end # returns true it the package is held (if the OS supports it) def held?(_provider = nil, _version = nil) info[:held] == true end # returns the package description def info return @cache unless @cache.nil? # All `@pkgman.info` methods return `{}`. This matches that # behavior if `@pkgman` can't be determined, thus avoiding the # `undefined method 'info' for nil:NilClass` error return {} if @pkgman.nil? @pkgman.info(@package_name) end # return the package version def version info = @pkgman.info(@package_name) info[:version] end def to_s "System Package #{@package_name}" end private def evaluate_missing_requirements missing_requirements_string = @pkgman.missing_requirements.uniq.join(", ") return if missing_requirements_string.empty? raise Inspec::Exceptions::ResourceSkipped, "The following requirements are not met for this resource: #{missing_requirements_string}" end end class PkgManagement attr_reader :inspec def initialize(inspec) @inspec = inspec end def missing_requirements # Each provider can provide an Array of missing requirements that will be # combined into a `ResourceSkipped` exception message. [] end end # Debian / Ubuntu class Deb < PkgManagement def info(package_name) cmd = inspec.command("dpkg -s #{package_name}") return {} if cmd.exit_status.to_i != 0 params = SimpleConfig.new( cmd.stdout.chomp, assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/, multiple_values: false ).params # If the package is installed, Status is "install ok installed" # If the package is installed and marked hold, Status is "hold ok installed" # If the package is removed and not purged, Status is "deinstall ok config-files" with exit_status 0 # If the package is purged cmd fails with non-zero exit status { name: params["Package"], installed: params["Status"].split(" ")[2] == "installed", held: params["Status"].split(" ")[0] == "hold", version: params["Version"], type: "deb", } end end # RHEL family class Rpm < PkgManagement def initialize(inspec, opts) super(inspec) @dbpath = opts.fetch(:rpm_dbpath, nil) end def missing_requirements missing_requirements = [] unless @dbpath.nil? || inspec.directory(@dbpath).directory? missing_requirements << "RPMDB #{@dbpath} does not exist" end missing_requirements end def info(package_name) rpm_cmd = rpm_command(package_name) cmd = inspec.command(rpm_cmd) # CentOS does not return an error code if the package is not installed, # therefore we need to check for emptyness return {} if cmd.exit_status.to_i != 0 || cmd.stdout.chomp.empty? params = SimpleConfig.new( cmd.stdout.chomp, assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/, multiple_values: false ).params # On some (all?) systems, the linebreak before the vendor line is missing if params["Version"] =~ /\s*Vendor:/ v = params["Version"].split(" ")[0] else v = params["Version"] end # On some (all?) systems, the linebreak before the build line is missing if params["Release"] =~ /\s*Build Date:/ r = params["Release"].split(" ")[0] else r = params["Release"] end { name: params["Name"], installed: true, version: "#{v}-#{r}", type: "rpm", } end private def rpm_command(package_name) cmd = "" cmd += "rpm -qi" cmd += " --dbpath #{@dbpath}" if @dbpath cmd += " " + package_name cmd end end # MacOS / Darwin implementation class Brew < PkgManagement def info(package_name) brew_path = inspec.command("brew").exist? ? "brew" : "/usr/local/bin/brew" cmd = inspec.command("#{brew_path} info --json=v1 #{package_name}") # If no available formula exists, then `brew` will exit non-zero return {} if cmd.exit_status.to_i != 0 pkg = JSON.parse(cmd.stdout)[0] # If package exists but is not installed, then `brew` output will not # contain `pkg['installed'][0]['version'] return {} unless pkg.dig("installed", 0, "version") { name: pkg["name"], installed: true, version: pkg["installed"][0]["version"], type: "brew", } rescue JSON::ParserError => e raise Inspec::Exceptions::ResourceFailed, "Failed to parse JSON from `brew` command. Error: #{e}" end end # Arch Linux class Pacman < PkgManagement def info(package_name) cmd = inspec.command("pacman -Qi #{package_name}") return {} if cmd.exit_status.to_i != 0 params = SimpleConfig.new( cmd.stdout.chomp, assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/, multiple_values: false ).params { name: params["Name"], installed: true, version: params["Version"], type: "pacman", } end end class HpuxPkg < PkgManagement def info(package_name) cmd = inspec.command("swlist -l product | grep #{package_name}") return {} if cmd.exit_status.to_i != 0 pkg = cmd.stdout.strip.split(" ") { name: pkg[0], installed: true, version: pkg[1], type: "pkg", } end end class AlpinePkg < PkgManagement def info(package_name) cmd = inspec.command("apk info -vv --no-network | grep #{package_name}") return {} if cmd.exit_status.to_i != 0 pkg_info = cmd.stdout.split("\n").delete_if { |e| e =~ /^WARNING/i } pkg = pkg_info[0].split(" - ")[0] { name: pkg.partition("-")[0], installed: true, version: pkg.partition("-")[2], type: "pkg", } end end class FreebsdPkg < PkgManagement def info(package_name) cmd = inspec.command("pkg info #{package_name}") return {} if cmd.exit_status.to_i != 0 params = SimpleConfig.new( cmd.stdout.chomp, assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/, multiple_values: false ).params { name: params["Name"], installed: true, version: params["Version"], type: "pkg", } end end # Determines the installed packages on Windows using the Windows package registry entries. # @see: http://blogs.technet.com/b/heyscriptingguy/archive/2013/11/15/use-powershell-to-find-installed-software.aspx class WindowsPkg < PkgManagement def info(package_name) search_paths = [ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', ] # add 64 bit search paths if inspec.os.arch == "x86_64" search_paths << 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' search_paths << 'HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' end # Find the package cmd = inspec.command <<-EOF.gsub(/^\s*/, "") Get-ItemProperty (@("#{search_paths.join('", "')}") | Where-Object { Test-Path $_ }) | Where-Object { $_.DisplayName -match "^\s*#{package_name.shellescape}\.*" -or $_.PSChildName -match "^\s*#{package_name.shellescape}\.*" } | Select-Object -Property DisplayName,DisplayVersion | ConvertTo-Json EOF # We cannot rely on `exit_status` since PowerShell always exits 0 from the # above command. Instead, if no package is found the output of the command # will be `''` so we can use that to return `{}` to match the behavior of # other package managers. return {} if cmd.stdout == "" begin package = JSON.parse(cmd.stdout) rescue JSON::ParserError => e raise Inspec::Exceptions::ResourceFailed, "Failed to parse JSON from PowerShell. Error: #{e}" end # What if we match multiple packages? just pick the first one for now. package = package[0] if package.is_a?(Array) { name: package["DisplayName"], installed: true, version: package["DisplayVersion"], type: "windows", } end end # AIX class BffPkg < PkgManagement def info(package_name) cmd = inspec.command("lslpp -cL #{package_name}") return {} if cmd.exit_status.to_i != 0 bff_pkg = cmd.stdout.split("\n").last.split(":") { name: bff_pkg[1], installed: true, version: bff_pkg[2], type: "bff", } end end # Solaris class SolarisPkg < PkgManagement def info(package_name) if inspec.os[:release].to_i <= 10 solaris10_info(package_name) else solaris11_info(package_name) end end # solaris 10 def solaris10_info(package_name) cmd = inspec.command("pkginfo -l #{package_name}") return {} if cmd.exit_status.to_i != 0 params = SimpleConfig.new( cmd.stdout.chomp, assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/, multiple_values: false ).params # parse 11.10.0,REV=2006.05.18.01.46 v = params["VERSION"].split(",") { name: params["PKGINST"], installed: true, version: v[0] + "-" + v[1].split("=")[1], type: "pkg", } end # solaris 11 def solaris11_info(package_name) cmd = inspec.command("pkg info #{package_name}") return {} if cmd.exit_status.to_i != 0 params = SimpleConfig.new( cmd.stdout.chomp, assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/, multiple_values: false ).params { name: params["Name"], installed: true, # 0.5.11-0.175.3.1.0.5.0 version: "#{params["Version"]}-#{params["Branch"]}", type: "pkg", } end end end