Repository: ansible-collections/community.general

Issue:
nmcli: Writing secrets to command line is a security hole (I.E. `wifi_sec.psk`)
I don't see any mention of this in #2220, but writing secrets to command line is a security hole. If possible I believe it would be best to add the `-a` or `--ask` flag to prompt for the password to be passed via `stdin`. `nmcli` also can be directed to a file containing the password which is preferable to command line, but still risky.

Snippet from the `nmcli` man pages:
```
 passwd-file
               some networks may require credentials during activation. You can give these credentials using this
               option. Each line of the file should contain one password in the form:

                   setting_name.property_name:the password

               For example, for WPA Wi-Fi with PSK, the line would be

                   802-11-wireless-security.psk:secret12345

               For 802.1X password, the line would be

                   802-1x.password:my 1X password

               nmcli also accepts wifi-sec and wifi strings instead of 802-11-wireless-security. When NetworkManager
               requires a password and it is not given, nmcli will ask for it when run with --ask. If --ask was not
               passed, NetworkManager can ask another secret agent that may be running (typically a GUI secret agent,
               such as nm-applet or gnome-shell).
```

_Originally posted by @Ajpantuso in https://github.com/ansible-collections/community.general/pull/3081#discussion_r680924068_

##### COMPONENT NAME
nmcli


Discussion:
Files identified in the description:
None

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 --->
@hummeltech: Greetings! Thanks for taking the time to open this issue. In order for the community to handle your issue effectively, we need a bit more information.

Here are the items we could not find in your description:
- component name

Please set the description of this issue with this template:
https://raw.githubusercontent.com/ansible/ansible/devel/.github/ISSUE_TEMPLATE/bug_report.md

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: issue_missing_data --->
Files identified in the description:
* [`net_tools/nmcli.py`](https://github.com/ansible-collections/community.general/blob/master/net_tools/nmcli.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 --->
Files identified in the description:
None

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 --->
!component =plugins/modules/net_tools/nmcli.py
> I don't see any mention of this in #2220, but writing secrets to command line is a security hole. If possible I believe it would be best to add the `-a` or `--ask` flag to prompt for the password to be passed via `stdin`. `nmcli` also can be directed to a file containing the password which is preferable to command line, but still risky.

stdin is the best way to pass passwords.
Files identified in the description:
* [`plugins/modules/net_tools/nmcli.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/net_tools/nmcli.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 @alcamie101
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
> > I don't see any mention of this in #2220, but writing secrets to command line is a security hole. If possible I believe it would be best to add the `-a` or `--ask` flag to prompt for the password to be passed via `stdin`. `nmcli` also can be directed to a file containing the password which is preferable to command line, but still risky.
> 
> stdin is the best way to pass passwords.

Yes, I agree. I do, however, also think that the option to specify it via a configuration option should still be available, albeit it perhaps not a recommended one.
From a user perspective they will always be providing the password via a module option. We however have to make sure that the `nmcli` binary is receiving the password via `stdin` rather than command line during the `run_command` call. The reference to using a password file was the alternative in question and I brought that up only to mention that it still bears risk (in case someone had the idea to utilize that).
Ahh indeed, yes I see what you mean, I might experiment with that.
@Ajpantuso I have come up with a solution for you to examine here:
https://github.com/hummeltech/community.general/compare/nmcli-wifi-sec...hummeltech:nmcli-wifi-sec-ask

Basically, I just used the `data` attribute of `run_command` to call `nmcli con edit` with any potential "`secrets`" passed in via `stdin` after completing a `nmcli con add` or `nmcli con modify`:
https://github.com/ansible/ansible/blob/devel/lib/ansible/module_utils/basic.py#L1860

-----

~~Also, it turns out we were duplicating all specified `wifi`-related options, which shouldn't have done any real harm, but I have corrected the issue:~~

~~All of the values were being `appended` to the `cmd` [here](https://github.com/hummeltech/community.general/compare/nmcli-wifi-sec...hummeltech:nmcli-wifi-sec-ask#diff-0beb963c40c22c48521d124bde17191aa121555e97ddf206ea1cd82f5d68941cL1073-L1084).~~

~~And then later `extended` with all of the values from `connection_options()` [here](https://github.com/hummeltech/community.general/compare/nmcli-wifi-sec...hummeltech:nmcli-wifi-sec-ask#diff-0beb963c40c22c48521d124bde17191aa121555e97ddf206ea1cd82f5d68941cL1095).~~

Resolving patch:
diff --git a/plugins/modules/net_tools/nmcli.py b/plugins/modules/net_tools/nmcli.py
--- a/plugins/modules/net_tools/nmcli.py
+++ b/plugins/modules/net_tools/nmcli.py
@@ -709,6 +709,15 @@ class Nmcli(object):
     platform = 'Generic'
     distribution = None
 
+    SECRET_OPTIONS = (
+        '802-11-wireless-security.leap-password',
+        '802-11-wireless-security.psk',
+        '802-11-wireless-security.wep-key0',
+        '802-11-wireless-security.wep-key1',
+        '802-11-wireless-security.wep-key2',
+        '802-11-wireless-security.wep-key3'
+    )
+
     def __init__(self, module):
         self.module = module
         self.state = module.params['state']
@@ -792,6 +801,8 @@ def __init__(self, module):
         else:
             self.ipv6_method = None
 
+        self.edit_commands = []
+
     def execute_command(self, cmd, use_unsafe_shell=False, data=None):
         if isinstance(cmd, list):
             cmd = [to_text(item) for item in cmd]
@@ -1079,12 +1090,17 @@ def connection_update(self, nmcli_command):
         # Constructing the command.
         for key, value in options.items():
             if value is not None:
+                if key in self.SECRET_OPTIONS:
+                    self.edit_commands += ['set %s %s' % (key, value)]
+                    continue
                 cmd.extend([key, value])
 
         return self.execute_command(cmd)
 
     def create_connection(self):
         status = self.connection_update('create')
+        if status[0] == 0 and self.edit_commands:
+            status = self.edit_connection()
         if self.create_connection_up:
             status = self.up_connection()
         return status
@@ -1105,7 +1121,15 @@ def remove_connection(self):
         return self.execute_command(cmd)
 
     def modify_connection(self):
-        return self.connection_update('modify')
+        status = self.connection_update('modify')
+        if status[0] == 0 and self.edit_commands:
+            status = self.edit_connection()
+        return status
+
+    def edit_connection(self):
+        data = "\n".join(self.edit_commands + ['save', 'quit'])
+        cmd = [self.nmcli_bin, 'con', 'edit', self.conn_name]
+        return self.execute_command(cmd, data=data)
 
     def show_connection(self):
         cmd = [self.nmcli_bin, '--show-secrets', 'con', 'show', self.conn_name]


Test patch:
diff --git a/tests/unit/plugins/modules/net_tools/test_nmcli.py b/tests/unit/plugins/modules/net_tools/test_nmcli.py
--- a/tests/unit/plugins/modules/net_tools/test_nmcli.py
+++ b/tests/unit/plugins/modules/net_tools/test_nmcli.py
@@ -697,6 +697,23 @@ def mocked_ethernet_connection_dhcp_to_static(mocker):
                ))
 
 
+@pytest.fixture
+def mocked_secure_wireless_create_failure(mocker):
+    mocker_set(mocker,
+               execute_return=(1, "", ""))
+
+
+@pytest.fixture
+def mocked_secure_wireless_modify_failure(mocker):
+    mocker_set(mocker,
+               connection_exists=True,
+               execute_return=None,
+               execute_side_effect=(
+                   (0, "", ""),
+                   (1, "", ""),
+               ))
+
+
 @pytest.fixture
 def mocked_dummy_connection_static_unchanged(mocker):
     mocker_set(mocker,
@@ -1652,6 +1669,52 @@ def test_create_secure_wireless(mocked_generic_connection_create, capfd):
     Test : Create secure wireless connection
     """
 
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    assert nmcli.Nmcli.execute_command.call_count == 2
+    arg_list = nmcli.Nmcli.execute_command.call_args_list
+    add_args, add_kw = arg_list[0]
+
+    assert add_args[0][0] == '/usr/bin/nmcli'
+    assert add_args[0][1] == 'con'
+    assert add_args[0][2] == 'add'
+    assert add_args[0][3] == 'type'
+    assert add_args[0][4] == 'wifi'
+    assert add_args[0][5] == 'con-name'
+    assert add_args[0][6] == 'non_existent_nw_device'
+
+    add_args_text = list(map(to_text, add_args[0]))
+    for param in ['connection.interface-name', 'wireless_non_existant',
+                  'ipv4.addresses', '10.10.10.10/24',
+                  '802-11-wireless.ssid', 'Brittany',
+                  '802-11-wireless-security.key-mgmt', 'wpa-psk']:
+        assert param in add_args_text
+
+    edit_args, edit_kw = arg_list[1]
+    assert edit_args[0][0] == '/usr/bin/nmcli'
+    assert edit_args[0][1] == 'con'
+    assert edit_args[0][2] == 'edit'
+    assert edit_args[0][3] == 'non_existent_nw_device'
+
+    edit_kw_data = edit_kw['data'].split()
+    for param in ['802-11-wireless-security.psk', 'VERY_SECURE_PASSWORD',
+                  'save',
+                  'quit']:
+        assert param in edit_kw_data
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert results['changed']
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SECURE_WIRELESS, indirect=['patch_ansible_module'])
+def test_create_secure_wireless_failure(mocked_secure_wireless_create_failure, capfd):
+    """
+    Test : Create secure wireless connection w/failure
+    """
+
     with pytest.raises(SystemExit):
         nmcli.main()
 
@@ -1671,16 +1734,88 @@ def test_create_secure_wireless(mocked_generic_connection_create, capfd):
     for param in ['connection.interface-name', 'wireless_non_existant',
                   'ipv4.addresses', '10.10.10.10/24',
                   '802-11-wireless.ssid', 'Brittany',
-                  '802-11-wireless-security.key-mgmt', 'wpa-psk',
-                  '802-11-wireless-security.psk', 'VERY_SECURE_PASSWORD']:
+                  '802-11-wireless-security.key-mgmt', 'wpa-psk']:
+        assert param in add_args_text
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert results.get('failed')
+    assert 'changed' not in results
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SECURE_WIRELESS, indirect=['patch_ansible_module'])
+def test_modify_secure_wireless(mocked_generic_connection_modify, capfd):
+    """
+    Test : Modify secure wireless connection
+    """
+
+    with pytest.raises(SystemExit):
+        nmcli.main()
+    assert nmcli.Nmcli.execute_command.call_count == 2
+    arg_list = nmcli.Nmcli.execute_command.call_args_list
+    add_args, add_kw = arg_list[0]
+
+    assert add_args[0][0] == '/usr/bin/nmcli'
+    assert add_args[0][1] == 'con'
+    assert add_args[0][2] == 'modify'
+    assert add_args[0][3] == 'non_existent_nw_device'
+
+    add_args_text = list(map(to_text, add_args[0]))
+    for param in ['connection.interface-name', 'wireless_non_existant',
+                  'ipv4.addresses', '10.10.10.10/24',
+                  '802-11-wireless.ssid', 'Brittany',
+                  '802-11-wireless-security.key-mgmt', 'wpa-psk']:
         assert param in add_args_text
 
+    edit_args, edit_kw = arg_list[1]
+    assert edit_args[0][0] == '/usr/bin/nmcli'
+    assert edit_args[0][1] == 'con'
+    assert edit_args[0][2] == 'edit'
+    assert edit_args[0][3] == 'non_existent_nw_device'
+
+    edit_kw_data = edit_kw['data'].split()
+    for param in ['802-11-wireless-security.psk', 'VERY_SECURE_PASSWORD',
+                  'save',
+                  'quit']:
+        assert param in edit_kw_data
+
     out, err = capfd.readouterr()
     results = json.loads(out)
     assert not results.get('failed')
     assert results['changed']
 
 
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SECURE_WIRELESS, indirect=['patch_ansible_module'])
+def test_modify_secure_wireless_failure(mocked_secure_wireless_modify_failure, capfd):
+    """
+    Test : Modify secure wireless connection w/failure
+    """
+
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    assert nmcli.Nmcli.execute_command.call_count == 2
+    arg_list = nmcli.Nmcli.execute_command.call_args_list
+    add_args, add_kw = arg_list[1]
+
+    assert add_args[0][0] == '/usr/bin/nmcli'
+    assert add_args[0][1] == 'con'
+    assert add_args[0][2] == 'modify'
+    assert add_args[0][3] == 'non_existent_nw_device'
+
+    add_args_text = list(map(to_text, add_args[0]))
+    for param in ['connection.interface-name', 'wireless_non_existant',
+                  'ipv4.addresses', '10.10.10.10/24',
+                  '802-11-wireless.ssid', 'Brittany',
+                  '802-11-wireless-security.key-mgmt', 'wpa-psk']:
+        assert param in add_args_text
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert results.get('failed')
+    assert 'changed' not in results
+
+
 @pytest.mark.parametrize('patch_ansible_module', TESTCASE_DUMMY_STATIC, indirect=['patch_ansible_module'])
 def test_create_dummy_static(mocked_generic_connection_create, capfd):
     """