require "forwardable" unless defined?(Forwardable)
require "inspec/utils/filter_array"
require "inspec/utils/filter"
require "inspec/utils/parser"

module Inspec::Resources
  class AuditDaemon < Inspec.resource(1)
    extend Forwardable
    attr_accessor :lines
    attr_reader :params

    name "auditd"
    supports platform: "unix"
    desc "Use the auditd InSpec audit resource to test the rules for logging that exist on the system. The audit.rules file is typically located under /etc/audit/ and contains the list of rules that define what is captured in log files. These rules are output using the auditcl -l command."
    example <<~EXAMPLE
      describe auditd.syscall('chown').where {arch == 'b32'} do
        its('action') { should eq ['always'] }
        its('list') { should eq ['exit'] }
      end

      describe auditd.where {key == 'privileged'} do
        its('permissions') { should include ['x'] }
      end

      describe auditd do
        its('lines') { should include %r(-w /etc/ssh/sshd_config) }
      end
    EXAMPLE

    def initialize
      @auditctl_cmd_str = inspec.os.name.eql?("alpine") ? "/usr/sbin/auditctl" : "/sbin/auditctl"
      unless inspec.command(@auditctl_cmd_str).exist?
        raise Inspec::Exceptions::ResourceFailed,
              "Command `#{@auditctl_cmd_str}` does not exist"
      end

      auditctl_cmd = "#{@auditctl_cmd_str} -l"
      result = inspec.command(auditctl_cmd)

      if result.exit_status != 0
        raise Inspec::Exceptions::ResourceFailed,
              "Command `#{auditctl_cmd}` failed with error: #{result.stderr}"
      end

      @content = result.stdout
      @params = []

      if @content =~ /^LIST_RULES:/
        raise Inspec::Exceptions::ResourceFailed,
              "The version of audit is outdated." \
              "The `auditd` resource supports versions of audit >= 2.3."
      end
      parse_content
    end

    filter = FilterTable.create
    filter.register_column(:file, field: "file")
      .register_column(:list,         field: "list")
      .register_column(:action,       field: "action")
      .register_column(:fields,       field: "fields")
      .register_column(:fields_nokey, field: "fields_nokey")
      .register_column(:syscall,      field: "syscall")
      .register_column(:key,          field: "key")
      .register_column(:arch,         field: "arch")
      .register_column(:path,         field: "path")
      .register_column(:permissions,  field: "permissions")
      .register_column(:exit,         field: "exit")

    filter.install_filter_methods_on_resource(self, :params)

    def status(name = nil)
      @status_content ||= inspec.command("#{@auditctl_cmd_str} -s").stdout.chomp

      # See: https://github.com/inspec/inspec/issues/3113
      if @status_content =~ /^AUDIT_STATUS/
        @status_content = @status_content.gsub("AUDIT_STATUS: ", "")
          .tr(" ", "\n")
          .tr("=", " ")
      end

      @status_params ||= Hash[@status_content.scan(/^([^ ]+) (.*)$/)]

      return @status_params[name] if name

      @status_params
    end

    def parse_content
      @lines = @content.lines.map(&:chomp)

      lines.each do |line|
        if is_file_syscall_syntax?(line)
          file_syscall_syntax_rules_for(line)
        end

        if is_syscall?(line)
          syscall_rules_for(line)

        elsif is_file?(line)
          file_rules_for(line)
        end
      end
    end

    def file_syscall_syntax_rules_for(line)
      file = file_syscall_syntax_for(line)
      action, list = action_list_for(line)
      fields = rule_fields_for(line)
      key_field, fields_nokey = remove_key_from(fields)
      key = key_in(key_field.join(""))
      perms = perms_in(fields)

      @params.push(
        {
          "file" => file,
          "list" => list,
          "action" => action,
          "fields" => fields,
          "permissions" => perms,
          "key" => key,
          "fields_nokey" => fields_nokey,
        }
      )
    end

    def syscall_rules_for(line)
      syscalls = syscalls_for(line)
      action, list = action_list_for(line)
      fields = rule_fields_for(line)
      key_field, fields_nokey = remove_key_from(fields)
      key = key_in(key_field.join(""))
      arch = arch_in(fields)
      path = path_in(fields)
      perms = perms_in(fields)
      exit_field = exit_in(fields)

      syscalls.each do |s|
        @params.push(
          {
            "syscall" => s,
            "list" => list,
            "action" => action,
            "fields" => fields,
            "key" => key,
            "arch" => arch,
            "path" => path,
            "permissions" => perms,
            "exit" => exit_field,
            "fields_nokey" => fields_nokey,
          }
        )
      end
    end

    def file_rules_for(line)
      file = file_for(line)
      perms = permissions_for(line)
      key = key_for(line)

      @params.push(
        {
          "file" => file,
          "key" => key,
          "permissions" => perms,
        }
      )
    end

    def to_s
      "Auditd Rules"
    end

    private

    def is_syscall?(line)
      line.match(/-S /)
    end

    def is_file?(line)
      line.match(/-w /)
    end

    def is_file_syscall_syntax?(line)
      line.match(/-F path=/)
    end

    def syscalls_for(line)
      line.scan(/-S ([^ ]+)\s?/).flatten.first.split(",")
    end

    # Processes the line and returns a pair of entries reflecting the 'action'
    # and 'list' items.
    #
    # @return [Array[String,String]]
    def action_list_for(line)
      action_list = line.scan(/-a ([^,\s]+),([^,\s]+)(?:\s|$)/).flatten

      # Actions and lists can be in either order
      valid_actions = %w{never always}

      [
        (action_list & valid_actions).first,
        (action_list - valid_actions).first,
      ]
    end

    def key_for(line)
      line.match(/-k ([^ ]+)\s?/)[1] if line.include?("-k ")
    end

    def file_for(line)
      line.match(/-w ([^ ]+)\s?/)[1]
    end

    def file_syscall_syntax_for(line)
      line.match(/-F path=(\S+)\s?/)[1]
    end

    def permissions_for(line)
      line.match(/-p ([^ ]+)/)[1].scan(/\w/)
    end

    def rule_fields_for(line)
      line.gsub(/-[aS] [^ ]+ /, "").split("-F ").map { |l| l.split(" ") }.flatten
    end

    def arch_in(fields)
      fields.each do |field|
        return field.match(/arch=(\S+)\s?/)[1] if field.start_with?("arch=")
      end
      nil
    end

    def perms_in(fields)
      fields.each do |field|
        return field.match(/perm=(\S+)\s?/)[1].scan(/\w/) if field.start_with?("perm=")
      end
      nil
    end

    def path_in(fields)
      fields.each do |field|
        return field.match(/path=(\S+)\s?/)[1] if field.start_with?("path=")
      end
      nil
    end

    def exit_in(fields)
      fields.each do |field|
        return field.match(/exit=(\S+)\s?/)[1] if field.start_with?("exit=")
      end
      nil
    end

    def key_in(field)
      _, v = field.split("=")
      v
    end

    def remove_key_from(fields)
      fields.partition { |x| x.start_with? "key" }
    end
  end
end
