lib/resources/user.rb



# encoding: utf-8
# author: Christoph Hartmann
# author: Dominik Richter

# Usage:
#
# describe user('root') do
#   it { should exist }
#   its(:uid) { should eq 0 }
#   its(:gid) { should eq 0 }
#   its(:group) { should eq 'root' }
#   its(:groups) { should eq ['root', 'wheel']}
#   its(:home) { should eq '/root' }
#   its(:shell) { should eq '/bin/bash' }
#   its(:mindays) { should eq 0 }
#   its(:maxdays) { should eq 99 }
#   its(:warndays) { should eq 5 }
# end
#
# The following  Serverspec  matchers are deprecated in favor for direct value access
#
# describe user('root') do
#   it { should belong_to_group 'root' }
#   it { should have_uid 0 }
#   it { should have_home_directory '/root' }
#   it { should have_login_shell '/bin/bash' }
#   its(:minimum_days_between_password_change) { should eq 0 }
#   its(:maximum_days_between_password_change) { should eq 99 }
# end

# ServerSpec tests that are not supported:
#
# describe user('root') do
#   it { should have_authorized_key 'ssh-rsa ADg54...3434 user@example.local' }
#   its(:encrypted_password) { should eq 1234 }
# end

require 'utils/parser'
require 'utils/convert'

class User < Inspec.resource(1) # rubocop:disable Metrics/ClassLength
  name 'user'
  desc 'Use the user InSpec audit resource to test user profiles, including the groups to which they belong, the frequency of required password changes, the directory paths to home and shell.'
  example "
    describe user('root') do
      it { should exist }
      its('uid') { should eq 1234 }
      its('gid') { should eq 1234 }
    end
  "
  def initialize(user)
    @user = user

    # select package manager
    @user_provider = nil
    os = inspec.os
    if os.linux?
      @user_provider = LinuxUser.new(inspec)
    elsif os.windows?
      @user_provider = WindowsUser.new(inspec)
    elsif ['darwin'].include?(os[:family])
      @user_provider = DarwinUser.new(inspec)
    elsif ['freebsd'].include?(os[:family])
      @user_provider = FreeBSDUser.new(inspec)
    elsif ['aix'].include?(os[:family])
      @user_provider = AixUser.new(inspec)
    elsif os.solaris?
      @user_provider = SolarisUser.new(inspec)
    else
      return skip_resource 'The `user` resource is not supported on your OS yet.'
    end
  end

  def exists?
    !identity.nil? && !identity[:user].nil?
  end

  def uid
    identity[:uid] unless identity.nil?
  end

  def gid
    identity[:gid] unless identity.nil?
  end

  def group
    identity[:group] unless identity.nil?
  end

  def groups
    identity[:groups] unless identity.nil?
  end

  def home
    meta_info[:home] unless meta_info.nil?
  end

  def shell
    meta_info[:shell] unless meta_info.nil?
  end

  # returns the minimum days between password changes
  def mindays
    credentials[:mindays] unless credentials.nil?
  end

  # returns the maximum days between password changes
  def maxdays
    credentials[:maxdays] unless credentials.nil?
  end

  # returns the days for password change warning
  def warndays
    credentials[:warndays] unless credentials.nil?
  end

  # implement 'mindays' method to be compatible with serverspec
  def minimum_days_between_password_change
    deprecated('minimum_days_between_password_change', "Please use 'its(:mindays)'")
    mindays
  end

  # implement 'maxdays' method to be compatible with serverspec
  def maximum_days_between_password_change
    deprecated('maximum_days_between_password_change', "Please use 'its(:maxdays)'")
    maxdays
  end

  # implements rspec has matcher, to be compatible with serverspec
  # @see: https://github.com/rspec/rspec-expectations/blob/master/lib/rspec/matchers/built_in/has.rb
  def has_uid?(compare_uid)
    deprecated('has_uid?')
    uid == compare_uid
  end

  def has_home_directory?(compare_home)
    deprecated('has_home_directory?', "Please use 'its(:home)'")
    home == compare_home
  end

  def has_login_shell?(compare_shell)
    deprecated('has_login_shell?', "Please use 'its(:shell)'")
    shell == compare_shell
  end

  def has_authorized_key?(_compare_key)
    deprecated('has_authorized_key?')
    fail NotImplementedError
  end

  def deprecated(name, alternative = nil)
    warn "[DEPRECATION] #{name} is deprecated. #{alternative}"
  end

  def to_s
    "User #{@user}"
  end

  def identity
    return @id_cache if defined?(@id_cache)
    @id_cache = @user_provider.identity(@user) if !@user_provider.nil?
  end

  private

  def meta_info
    return @meta_cache if defined?(@meta_cache)
    @meta_cache = @user_provider.meta_info(@user) if !@user_provider.nil?
  end

  def credentials
    return @cred_cache if defined?(@cred_cache)
    @cred_cache = @user_provider.credentials(@user) if !@user_provider.nil?
  end
end

class UserInfo
  include Converter

  attr_reader :inspec
  def initialize(inspec)
    @inspec = inspec
  end

  def credentials(_username)
  end
end

# implements generic unix id handling
class UnixUser < UserInfo
  attr_reader :inspec, :id_cmd
  def initialize(inspec)
    @inspec = inspec
    @id_cmd ||= 'id'
    super
  end

  # parse one id entry like '0(wheel)''
  def parse_value(line)
    SimpleConfig.new(
      line,
      line_separator: ',',
      assignment_re: /^\s*([^\(]*?)\s*\(\s*(.*?)\)*$/,
      group_re: nil,
      multiple_values: false,
    ).params
  end

  # extracts the identity
  def identity(username)
    cmd = inspec.command("#{id_cmd} #{username}")
    return nil if cmd.exit_status != 0

    # parse words
    params = SimpleConfig.new(
      parse_id_entries(cmd.stdout.chomp),
      assignment_re: /^\s*([^=]*?)\s*=\s*(.*?)\s*$/,
      group_re: nil,
      multiple_values: false,
    ).params

    {
      uid: convert_to_i(parse_value(params['uid']).keys[0]),
      user: parse_value(params['uid']).values[0],
      gid: convert_to_i(parse_value(params['gid']).keys[0]),
      group: parse_value(params['gid']).values[0],
      groups: parse_value(params['groups']).values,
    }
  end

  # splits the results of id into seperate lines
  def parse_id_entries(raw)
    data = []
    until (index = raw.index(/\)\s{1}/)).nil?
      data.push(raw[0, index+1]) # inclue closing )
      raw = raw[index+2, raw.length-index-2]
    end
    data.push(raw) if !raw.nil?
    data.join("\n")
  end
end

class LinuxUser < UnixUser
  include PasswdParser
  include CommentParser

  def meta_info(username)
    cmd = inspec.command("getent passwd #{username}")
    return nil if cmd.exit_status != 0
    # returns: root:x:0:0:root:/root:/bin/bash
    passwd = parse_passwd_line(cmd.stdout.chomp)
    {
      home: passwd['home'],
      shell: passwd['shell'],
    }
  end

  def credentials(username)
    cmd = inspec.command("chage -l #{username}")
    return nil if cmd.exit_status != 0

    params = SimpleConfig.new(
      cmd.stdout.chomp,
      assignment_re: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/,
      group_re: nil,
      multiple_values: false,
    ).params

    {
      mindays: convert_to_i(params['Minimum number of days between password change']),
      maxdays: convert_to_i(params['Maximum number of days between password change']),
      warndays: convert_to_i(params['Number of days of warning before password expires']),
    }
  end
end

class SolarisUser < LinuxUser
  def initialize(inspec)
    @inspec = inspec
    @id_cmd ||= 'id -a'
    super
  end

  def credentials(_username)
    nil
  end
end

class AixUser < UnixUser
  def identity(username)
    id = super(username)
    return nil if id.nil?
    # AIX 'id' command doesn't include the primary group in the supplementary
    # yet it can be somewhere in the supplementary list if someone added root
    # to a groups list in /etc/group
    # we rearrange to expected list if that is the case
    if id[:groups].first != id[:group]
      id[:groups].reject! { |i| i == id[:group] } if id[:groups].include?(id[:group])
      id[:groups].unshift(id[:group])
    end

    id
  end

  def meta_info(username)
    lsuser = inspec.command("lsuser -C -a home shell #{username}")
    return nil if lsuser.exit_status != 0

    user = lsuser.stdout.chomp.split("\n").last.split(':')
    {
      home:  user[1],
      shell: user[2],
    }
  end

  def credentials(username)
    cmd = inspec.command(
      "lssec -c -f /etc/security/user -s #{username} -a minage -a maxage -a pwdwarntime",
    )
    return nil if cmd.exit_status != 0

    user_sec = cmd.stdout.chomp.split("\n").last.split(':')

    {
      mindays:  user_sec[1].to_i * 7,
      maxdays:  user_sec[2].to_i * 7,
      warndays: user_sec[3].to_i,
    }
  end
end

# we do not use 'finger' for MacOS, because it is harder to parse data with it
# @see https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man8/fingerd.8.html
# instead we use 'dscl' to request user data
# @see https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man1/dscl.1.html
# @see http://superuser.com/questions/592921/mac-osx-users-vs-dscl-command-to-list-user
class DarwinUser < UnixUser
  def meta_info(username)
    cmd = inspec.command("dscl -q . -read /Users/#{username} NFSHomeDirectory PrimaryGroupID RecordName UniqueID UserShell")
    return nil if cmd.exit_status != 0

    params = SimpleConfig.new(
      cmd.stdout.chomp,
      assignment_re: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/,
      group_re: nil,
      multiple_values: false,
    ).params

    {
      home: params['NFSHomeDirectory'],
      shell: params['UserShell'],
    }
  end
end

# FreeBSD recommends to use the 'pw' command for user management
# @see: https://www.freebsd.org/doc/handbook/users-synopsis.html
# @see: https://www.freebsd.org/cgi/man.cgi?pw(8)
# It offers the following commands:
# - adduser(8)	The recommended command-line application for adding new users.
# - rmuser(8)	The recommended command-line application for removing users.
# - chpass(1)	A flexible tool for changing user database information.
# - passwd(1)	The command-line tool to change user passwords.
class FreeBSDUser < UnixUser
  include PasswdParser

  def meta_info(username)
    cmd = inspec.command("pw usershow #{username} -7")
    return nil if cmd.exit_status != 0
    # returns: root:*:0:0:Charlie &:/root:/bin/csh
    passwd = parse_passwd_line(cmd.stdout.chomp)
    {
      home: passwd['home'],
      shell: passwd['shell'],
    }
  end
end

# For now, we stick with WMI Win32_UserAccount
# @see https://msdn.microsoft.com/en-us/library/aa394507(v=vs.85).aspx
# @see https://msdn.microsoft.com/en-us/library/aa394153(v=vs.85).aspx
#
# using Get-AdUser would be the best command for domain machines, but it will not be installed
# on client machines by default
# @see https://technet.microsoft.com/en-us/library/ee617241.aspx
# @see https://technet.microsoft.com/en-us/library/hh509016(v=WS.10).aspx
# @see http://woshub.com/get-aduser-getting-active-directory-users-data-via-powershell/
# @see http://stackoverflow.com/questions/17548523/the-term-get-aduser-is-not-recognized-as-the-name-of-a-cmdlet
#
# Just for reference, we could also use ADSI (Active Directory Service Interfaces)
# @see https://mcpmag.com/articles/2015/04/15/reporting-on-local-accounts.aspx
class WindowsUser < UserInfo
  # parse windows account name
  def parse_windows_account(username)
    account = username.split('\\')
    name = account.pop
    domain = account.pop if account.size > 0
    [name, domain]
  end

  def identity(username)
    # extract domain/user information
    account, domain = parse_windows_account(username)

    # TODO: escape content
    if !domain.nil?
      filter = "Name = '#{account}' and Domain = '#{domain}'"
    else
      filter = "Name = '#{account}' and LocalAccount = true"
    end

    script = <<-EOH
      # find user
      $user = Get-WmiObject Win32_UserAccount -filter "#{filter}"
      # get related groups
      $groups = $user.GetRelated('Win32_Group') | Select-Object -Property Caption, Domain, Name, LocalAccount, SID, SIDType, Status
      # filter user information
      $user = $user | Select-Object -Property Caption, Description, Domain, Name, LocalAccount, Lockout, PasswordChangeable, PasswordExpires, PasswordRequired, SID, SIDType, Status
      # build response object
      New-Object -Type PSObject | `
      Add-Member -MemberType NoteProperty -Name User -Value ($user) -PassThru | `
      Add-Member -MemberType NoteProperty -Name Groups -Value ($groups) -PassThru | `
      ConvertTo-Json
    EOH

    cmd = inspec.script(script)

    # cannot rely on exit code for now, successful command returns exit code 1
    # return nil if cmd.exit_status != 0, try to parse json
    begin
      params = JSON.parse(cmd.stdout)
    rescue JSON::ParserError => _e
      return nil
    end

    user = params['User']['Caption'] unless params['User'].nil?
    groups = params['Groups']
    # if groups is no array, generate one
    groups = [groups] if !groups.is_a?(Array)
    groups = groups.map { |grp| grp['Caption'] } unless params['Groups'].nil?

    {
      uid: nil,
      user: user,
      gid: nil,
      group: nil,
      groups: groups,
    }
  end

  # not implemented yet
  def meta_info(_username)
    {
      home: nil,
      shell: nil,
    }
  end
end