Repository: ansible/ansible

Issue:
pamd failing on documented example
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
pamd module

##### ANSIBLE VERSION
```
ansible 2.4.3.0
  config file = /home/adam/code/ansible-mta/ansible.cfg
  configured module search path = ['/home/adam/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/adam/venv/ansible-mta-py3/lib/python3.6/site-packages/ansible
  executable location = /home/adam/venv/ansible-mta-py3/bin/ansible
  python version = 3.6.3 (default, Oct  3 2017, 21:45:48) [GCC 7.2.0]
```
I also noticed this on 2.4.2.0 and upgraded to 2.4.3.0 to try and resolve it.

##### CONFIGURATION
ANSIBLE_NOCOWS(/home/adam/code/ansible-mta/ansible.cfg) = True
ANSIBLE_PIPELINING(/home/adam/code/ansible-mta/ansible.cfg) = True
CACHE_PLUGIN(/home/adam/code/ansible-mta/ansible.cfg) = memory
COMMAND_WARNINGS(/home/adam/code/ansible-mta/ansible.cfg) = False
DEFAULT_FORKS(/home/adam/code/ansible-mta/ansible.cfg) = 50
DEFAULT_GATHERING(/home/adam/code/ansible-mta/ansible.cfg) = explicit
DEFAULT_HOST_LIST(/home/adam/code/ansible-mta/ansible.cfg) = ['/home/adam/code/ansible-mta/environments/local']
DEFAULT_LOG_PATH(/home/adam/code/ansible-mta/ansible.cfg) = /tmp/ansible-mta-adam.log
DEFAULT_MANAGED_STR(/home/adam/code/ansible-mta/ansible.cfg) = Ansible Managed
DEFAULT_ROLES_PATH(/home/adam/code/ansible-mta/ansible.cfg) = ['/home/adam/code/ansible-mta/roles']
HOST_KEY_CHECKING(/home/adam/code/ansible-mta/ansible.cfg) = False
RETRY_FILES_ENABLED(/home/adam/code/ansible-mta/ansible.cfg) = False

##### OS / ENVIRONMENT
Host: Ubuntu 17.10
Targets: Ubuntu 16.04 and CentOS 6 (identical errors)

##### SUMMARY
When running one of the "example" items from the pamd module documentation ( https://docs.ansible.com/ansible/latest/pamd_module.html#examples ), I get a stack trace. I also independently verified the commands (which are appropriate to my configuration) so I believe the example is correct.

##### STEPS TO REPRODUCE
Run the following playbook against a clean Ubuntu 16.04 machine.

<!--- Paste example playbooks or commands between quotes below -->
Example playbook, functionally equivalent to one of the examples in the module docs:
```yaml
---
- hosts: test
  tasks:
    - name: Prevent su except by wheel group
      become: true
      pamd:
        name: su
        type: auth
        control: sufficient
        module_path: pam_rootok.so
        new_type: auth
        new_control: required
        new_module_path: pam_wheel.so
        module_arguments: 'use_uid'
        state: after
```

##### EXPECTED RESULTS
Should add a line to the pam config like `auth required pam_wheel.so use_uid`.

##### ACTUAL RESULTS
Module fails and traceback.

Just the traceback:
```
The full traceback is:
Traceback (most recent call last):
  File "/tmp/ansible_TpFc0r/ansible_module_pamd.py", line 651, in <module>
    main()
  File "/tmp/ansible_TpFc0r/ansible_module_pamd.py", line 606, in main
    pamd.load_rules_from_file()
  File "/tmp/ansible_TpFc0r/ansible_module_pamd.py", line 328, in load_rules_from_file
    self.load_rules_from_string(stringline)
  File "/tmp/ansible_TpFc0r/ansible_module_pamd.py", line 344, in load_rules_from_string
    self.rules.append(PamdRule.rulefromstring(stringline))
  File "/tmp/ansible_TpFc0r/ansible_module_pamd.py", line 279, in rulefromstring
    rule_type = result.group(1)
AttributeError: 'NoneType' object has no attribute 'group'
```

```
$ ansible-playbook -vvvv -l test-ubuntu16, --diff --vault-id local@prompt roles/users/tests/test.yml

ansible-playbook 2.4.3.0
  config file = /home/adam/code/ansible-mta/ansible.cfg
  configured module search path = ['/home/adam/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/adam/venv/ansible-mta-py3/lib/python3.6/site-packages/ansible
  executable location = /home/adam/venv/ansible-mta-py3/bin/ansible-playbook
  python version = 3.6.3 (default, Oct  3 2017, 21:45:48) [GCC 7.2.0]
Using /home/adam/code/ansible-mta/ansible.cfg as config file 
Vault password (local): 
setting up inventory plugins
Parsed /home/adam/code/ansible-mta/environments/local/vagrant_ansible_inventory inventory source with ini plugin
Loading callback plugin default of type stdout, v2.0 from /home/adam/venv/ansible-mta-py3/lib/python3.6/site-packages/ansible/plugins/callback/default.py

PLAYBOOK: test.yml *************************************************************************************************************************
1 plays in roles/users/tests/test.yml

PLAY [test] ********************************************************************************************************************************
META: ran handlers
Trying secret <ansible.parsing.vault.PromptVaultSecret object at 0x7fcafece3080> for vault_id=local

TASK [Prevent su except by wheel group] ****************************************************************************************************
task path: /home/adam/code/ansible-mta/roles/users/tests/test.yml:8
Using module file /home/adam/venv/ansible-mta-py3/lib/python3.6/site-packages/ansible/modules/system/pamd.py
<127.0.0.1> ESTABLISH SSH CONNECTION FOR USER: vagrant
<127.0.0.1> SSH: EXEC ssh -vvv -C -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no -o Port=2022 -o 'IdentityFile="/home/adam/code/ansible-mta/.vagrant/machines/test-ubuntu16/virtualbox/private_key"' -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o User=vagrant -o ConnectTimeout=10 -o ControlPath=/home/adam/.ansible/cp/8752954ab8 127.0.0.1 '/bin/sh -c '"'"'sudo -H -S -n -u root /bin/sh -c '"'"'"'"'"'"'"'"'echo BECOME-SUCCESS-cmbfaaexzpdzxcuupxemlrwmjffbgeku; /usr/bin/python'"'"'"'"'"'"'"'"' && sleep 0'"'"''
<127.0.0.1> (1, b'', b'OpenSSH_7.5p1 Ubuntu-10ubuntu0.1, OpenSSL 1.0.2g  1 Mar 2016\r\ndebug1: Reading configuration data /home/adam/.ssh/config\r\ndebug1: /home/adam/.ssh/config line 58: Applying options for *\r\ndebug1: Reading configuration data /etc/ssh/ssh_config\r\ndebug1: /etc/ssh/ssh_config line 19: Applying options for *\r\ndebug1: auto-mux: Trying existing master\r\ndebug2: fd 4 setting O_NONBLOCK\r\ndebug2: mux_client_hello_exchange: master version 4\r\ndebug3: mux_client_forwards: request forwardings: 0 local, 0 remote\r\ndebug3: mux_client_request_session: entering\r\ndebug3: mux_client_request_alive: entering\r\ndebug3: mux_client_request_alive: done pid = 24030\r\ndebug3: mux_client_request_session: session request sent\r\ndebug1: mux_client_request_session: master session id: 2\r\nTraceback (most recent call last):\n  File "/tmp/ansible_qMwnEL/ansible_module_pamd.py", line 651, in <module>\n    main()\n  File "/tmp/ansible_qMwnEL/ansible_module_pamd.py", line 606, in main\n    pamd.load_rules_from_file()\n  File "/tmp/ansible_qMwnEL/ansible_module_pamd.py", line 328, in load_rules_from_file\n    self.load_rules_from_string(stringline)\n  File "/tmp/ansible_qMwnEL/ansible_module_pamd.py", line 344, in load_rules_from_string\n    self.rules.append(PamdRule.rulefromstring(stringline))\n  File "/tmp/ansible_qMwnEL/ansible_module_pamd.py", line 279, in rulefromstring\n    rule_type = result.group(1)\nAttributeError: \'NoneType\' object has no attribute \'group\'\ndebug3: mux_client_read_packet: read header failed: Broken pipe\r\ndebug2: Received exit status from master 1\r\n')
fatal: [test-ubuntu16]: FAILED! => {
    "changed": false,
    "module_stderr": "OpenSSH_8.5p1 Ubuntu-10ubuntu0.1, OpenSSL 1.0.2g  1 Mar 2016\r\ndebug1: Reading configuration data /home/adam/.ssh/config\r\ndebug1: /home/adam/.ssh/config line 58: Applying options for *\r\ndebug1: Reading configuration data /etc/ssh/ssh_config\r\ndebug1: /etc/ssh/ssh_config line 19: Applying options for *\r\ndebug1: auto-mux: Trying existing master\r\ndebug2: fd 4 setting O_NONBLOCK\r\ndebug2: mux_client_hello_exchange: master version 4\r\ndebug3: mux_client_forwards: request forwardings: 0 local, 0 remote\r\ndebug3: mux_client_request_session: entering\r\ndebug3: mux_client_request_alive: entering\r\ndebug3: mux_client_request_alive: done pid = 24030\r\ndebug3: mux_client_request_session: session request sent\r\ndebug1: mux_client_request_session: master session id: 2\r\nTraceback (most recent call last):\n  File \"/tmp/ansible_qMwnEL/ansible_module_pamd.py\", line 651, in <module>\n    main()\n  File \"/tmp/ansible_qMwnEL/ansible_module_pamd.py\", line 606, in main\n    pamd.load_rules_from_file()\n  File \"/tmp/ansible_qMwnEL/ansible_module_pamd.py\", line 328, in load_rules_from_file\n    self.load_rules_from_string(stringline)\n  File \"/tmp/ansible_qMwnEL/ansible_module_pamd.py\", line 344, in load_rules_from_string\n    self.rules.append(PamdRule.rulefromstring(stringline))\n  File \"/tmp/ansible_qMwnEL/ansible_module_pamd.py\", line 279, in rulefromstring\n    rule_type = result.group(1)\nAttributeError: 'NoneType' object has no attribute 'group'\ndebug3: mux_client_read_packet: read header failed: Broken pipe\r\ndebug2: Received exit status from master 1\r\n",
    "module_stdout": "",
    "msg": "MODULE FAILURE",
    "rc": 1
}

PLAY RECAP *********************************************************************************************************************************
test-ubuntu16              : ok=0    changed=0    unreachable=0    failed=1   
```



Discussion:
Files identified in the description:
* [lib/ansible/modules/system/pamd.py](https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/system/pamd.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @kevensen
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@xcorvis can you please share your pam_rootok.so file?
needs_info
pam_rootok.so is a binary and shouldn't be affected by this operation. I think you're actually wanting to look at the /etc/pam.d/su file. Either way, they're default for ubuntu 16.04, I have not changed them at all.

/etc/pam.d/su:
```
#
# The PAM configuration file for the Shadow `su' service
#

# This allows root to su without passwords (normal operation)
auth       sufficient pam_rootok.so

# Uncomment this to force users to be a member of group root
# before they can use `su'. You can also add "group=foo"
# to the end of this line if you want to use a group other
# than the default "root" (but this may have side effect of
# denying "root" user, unless she's a member of "foo" or explicitly
# permitted earlier by e.g. "sufficient pam_rootok.so").
# (Replaces the `SU_WHEEL_ONLY' option from login.defs)
# auth       required   pam_wheel.so

# Uncomment this if you want wheel members to be able to
# su without a password.
# auth       sufficient pam_wheel.so trust

# Uncomment this if you want members of a specific group to not
# be allowed to use su at all.
# auth       required   pam_wheel.so deny group=nosu

# Uncomment and edit /etc/security/time.conf if you need to set
# time restrainst on su usage.
# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs
# as well as /etc/porttime)
# account    requisite  pam_time.so

# This module parses environment configuration file(s)
# and also allows you to use an extended config
# file /etc/security/pam_env.conf.
# 
# parsing /etc/environment needs "readenv=1"
session       required   pam_env.so readenv=1
# locale variables are also kept into /etc/default/locale in etch
# reading this file *in addition to /etc/environment* does not hurt
session       required   pam_env.so readenv=1 envfile=/etc/default/locale

# Defines the MAIL environment variable
# However, userdel also needs MAIL_DIR and MAIL_FILE variables
# in /etc/login.defs to make sure that removing a user 
# also removes the user's mail spool file.
# See comments in /etc/login.defs
#
# "nopen" stands to avoid reporting new mail when su'ing to another user
session    optional   pam_mail.so nopen

# Sets up user limits according to /etc/security/limits.conf
# (Replaces the use of /etc/limits in old login)
session    required   pam_limits.so

# The standard Unix authentication modules, used with
# NIS (man nsswitch) as well as normal /etc/passwd and
# /etc/shadow entries.
@include common-auth
@include common-account
@include common-session
```
@xcorvis Silly me.  Yes, I meant the /etc/pam.d/su file.  Thanks.  I'll take a look.

Resolving patch:
diff --git a/lib/ansible/modules/system/pamd.py b/lib/ansible/modules/system/pamd.py
--- a/lib/ansible/modules/system/pamd.py
+++ b/lib/ansible/modules/system/pamd.py
@@ -302,14 +302,13 @@ def rulefromstring(cls, stringline):
             complicated = True
         else:
             pattern = re.compile(
-                r"""([\-A-Za-z0-9_]+)\s*        # Rule Type
-                    ([A-Za-z0-9_]+)\s*          # Rule Control
-                    ([A-Za-z0-9/_\-\.]+)\s*        # Rule Path
+                r"""([@\-A-Za-z0-9_]+)\s*        # Rule Type
+                    ([A-Za-z0-9_\-]+)\s*          # Rule Control
+                    ([A-Za-z0-9/_\-\.]*)\s*        # Rule Path
                     ([A-Za-z0-9,_=<>\-\s\./]*)""",  # Rule Args
                 re.X)
 
         result = pattern.match(stringline)
-
         rule_type = result.group(1)
         if complicated:
             rule_control = '[' + result.group(2) + ']'
@@ -353,13 +352,13 @@ def __init__(self, ansible=None):
             self.name = self.ansible.params["name"]
 
     def load_rules_from_file(self):
-        self.fname = self.path + "/" + self.name
+        self.fname = os.path.join(self.path, self.name)
         stringline = ''
         try:
             for line in open(self.fname, 'r'):
-                stringline += line.rstrip()
+                stringline += line.rstrip().lstrip()
                 stringline += '\n'
-            self.load_rules_from_string(stringline)
+            self.load_rules_from_string(stringline.replace("\\\n", ""))
 
         except IOError:
             e = get_exception()
@@ -375,6 +374,10 @@ def load_rules_from_string(self, stringvalue):
             elif (not line.startswith('#') and
                   not line.isspace() and
                   len(line) != 0):
+                try:
+                    self.ansible.log(msg="Creating rule from string %s" % stringline)
+                except AttributeError:
+                    pass
                 self.rules.append(PamdRule.rulefromstring(stringline))
 
     def write(self):


Test patch:
diff --git a/test/units/modules/system/test_pamd.py b/test/units/modules/system/test_pamd.py
--- a/test/units/modules/system/test_pamd.py
+++ b/test/units/modules/system/test_pamd.py
@@ -98,10 +98,13 @@ def setUp(self):
 auth      \tsufficient\tpam_unix.so nullok try_first_pass
 auth      \trequisite\tpam_succeed_if.so uid
 auth      \trequired\tpam_deny.so
+auth      \tsufficient\tpam_rootok.so
 
 account   \trequired\tpam_unix.so
 account   \tsufficient\tpam_localuser.so
 account   \tsufficient\tpam_succeed_if.so uid
+account		[success=1 default=ignore] \
+\t\t\t\tpam_succeed_if.so user = vagrant use_uid quiet
 account   \trequired\tpam_permit.so
 account   \trequired\tpam_access.so listsep=,
 session   \tinclude\tsystem-auth
@@ -115,15 +118,14 @@ def setUp(self):
 -session  \toptional\tpam_systemd.so
 session   \t[success=1 default=ignore]\tpam_succeed_if.so service in crond quiet use_uid
 session   \t[success=1 test=me default=ignore]\tpam_succeed_if.so service in crond quiet use_uid
-session   \trequired\tpam_unix.so"""
+session   \trequired\tpam_unix.so
+@include  \tcommon-auth
+@include  \tcommon-account
+@include  \tcommon-session"""
 
         self.pamd = PamdService()
         self.pamd.load_rules_from_string(self.system_auth_string)
 
-    def test_load_rule_from_string(self):
-
-        self.assertEqual(self.system_auth_string.rstrip().replace("\n\n", "\n"), str(self.pamd).rstrip().replace("\n\n", "\n"))
-
     def test_update_rule_type(self):
         old_rule = PamdRule.rulefromstring('auth      	required	pam_env.so')
         new_rule = PamdRule.rulefromstring('session      	required	pam_env.so')
@@ -212,6 +214,15 @@ def test_insert_after_rule(self):
         line_to_test += str(new_rule).rstrip()
         self.assertIn(line_to_test, str(self.pamd))
 
+    def test_insert_after_rule_another(self):
+        old_rule = PamdRule.rulefromstring('auth   	sufficient	pam_rootok.so')
+        new_rule = PamdRule.rulefromstring('auth   	required	pam_wheel.so use_id')
+        insert_after_rule(self.pamd, old_rule, new_rule)
+        line_to_test = str(old_rule).rstrip()
+        line_to_test += '\n'
+        line_to_test += str(new_rule).rstrip()
+        self.assertIn(line_to_test, str(self.pamd))
+
     def test_insert_after_rule_last_rule(self):
         old_rule = PamdRule.rulefromstring('session   	required	pam_unix.so')
         new_rule = PamdRule.rulefromstring('session   	required	pam_permit.so arg1 arg2 arg3')