Repository: sopel-irc/sopel

Issue:
Hide input of passwords in wizard
This is a 6.0 regression, which I might or might not get done for the actual release.

admin: censor settings more appropriately
`admin.py` can be used to display setting values. Some setting values "should not" be displayed (e.g., password, secret keys), however. 

Currently, the plugin censors any setting that `endswith()` "password". Other settings that may not exactly be passwords, but are just as valuable, should similarly be censored. What is the best option to catch settings that should be prevented from being displayed?

Current affected settings (not exhaustive):
- `core.auth_password`
- `github.client_key`
- `github.secret` (this together with `client_key` should _definitely_ not be revealed)

Is `"password" is in` better than `endswith("password")` (similar for "secret", etc...)? If there is a setting like `secret_key` or `password_for_site`, `endswith()` will obviously not censor those. While those examples may not be the best setting names, and can certainly be changed to meet the `endswith()` condition, there is no documentation (I think...) that suggests settings be named in such a way if this behavior is desired.

Also, should `core.owner` be able to view the setting value uncensored since this will be happening in privmsg anyway?

Finally, if a value is censored from a `core.admins` user, should they even be allowed to set it?

_Originally brought up by @dgw in https://github.com/sopel-irc/sopel/issues/1570#issuecomment-486738630_



Discussion:
I should just fix it, since I'm already working on the wizard & the config... :smile: 
@Exirel Slot this into 7.0 if you think it's doable in a few days, or 7.1 if not. It's been broken for this long, so it's not _that_ big a deal if it slips out of 7.0. Even though I actually just missed milestoning it. 😅
Well, this got long…

----

Easy enough to build a `sensitive_keywords` (or whatever) list and then censor settings with:

```py
if any(word in setting_name for word in sensitive_keywords):
    value = "(censored)"
```

Though that is a naïve approach, and could falsely match a lot of non-sensitive setting names depending on what goes in the `sensitive_keywords` list. It would be a bit more robust to try and enforce word boundaries (`_` either before or after the keyword), but still not perfect—and like with the current implementation, it would need to be documented. There's nothing stopping module developers from using `onelongvaluename` except convention. (Actually, this has only convinced me that, whatever the eventual implementation, this censoring feature needs to be documented in the website's [configuration tutorial](https://github.com/sopel-irc/sopel.chat/blob/d81cbdc036453e597385ed8c66fdb512f4c84637/_tutorials/part-2-config-wizard-and-setup-functions.md).)

> Currently, the plugin censors any setting that `endswith()` "password".

Also "pass": https://github.com/sopel-irc/sopel/blob/39e3680db18c4bed9801d040f7486c655b95a9a0/sopel/modules/admin.py#L295

But this is just a minor detail. :stuck_out_tongue:

> * `github.client_key`

This setting doesn't exist. (Also a minor detail.) The `client_id` (which does) is effectively public information, since it's displayed in the URL when a user edits their connection to an OAuth application: `https://github.com/settings/connections/applications/client_id_here`.

> Also, should `core.owner` be able to view the setting value uncensored since this will be happening in privmsg anyway?

No. The point is to prevent sensitive setting values from being logged, either on the user's end (if their client has logging enabled) or the bot's end (if e.g. `raw_log` is turned on—as it was by default [until 6.6.0](https://sopel.chat/changelog/6.6.0/#core-changes)).

And even if we stop worrying about logging, it's never a good idea to make plaintext passwords recoverable from where they are stored. It's not even ideal that Sopel's config file stores passwords in plain text in the first place… (Might be another issue for you to write here, if you're interested. If FileZilla can encrypt its saved passwords, so can we! I'm thinking a `SecureAttribute` config type that gets overwritten with an encrypted version at config load… No, @dgw, stay on topic!)

> Finally, if a value is censored from a `core.admins` user, should they even be allowed to set it?

Initially, my answer was "of course", with the logic that even though you can't see the old password, if you need to change it you should be able to.

But then I kept thinking about those logging considerations just discussed above. If sending the current/old value is undesirable because it might be logged, setting a new value via IRC message probably should be, too. Here, as always, we encounter the age-old "security vs. UX" conundrum.

If Sopel was its own ircd, it could get around this the way BitlBee (for example) does, by supporting setting secret values via the `OPER` command… but we're not a server, we're a client. I don't know of any client-to-client messages that are exempt from logging. :frowning:
Milestoned to go with #1600, since that's probably the best way to implement this.

Resolving patch:
diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py
--- a/sopel/config/core_section.py
+++ b/sopel/config/core_section.py
@@ -9,6 +9,7 @@
     FilenameAttribute,
     ListAttribute,
     NO_DEFAULT,
+    SecretAttribute,
     StaticSection,
     ValidatedAttribute,
 )
@@ -158,7 +159,7 @@ class CoreSection(StaticSection):
         value will override :attr:`server_auth_method`.
     """
 
-    auth_password = ValidatedAttribute('auth_password')
+    auth_password = SecretAttribute('auth_password')
     """The password to use to authenticate with the :attr:`auth_method`.
 
     See :ref:`Authentication`.
@@ -306,7 +307,7 @@ class CoreSection(StaticSection):
     Ignored when using SQLite.
     """
 
-    db_pass = ValidatedAttribute('db_pass')
+    db_pass = SecretAttribute('db_pass')
     """The password for Sopel's database.
 
     Ignored when using SQLite.
@@ -786,7 +787,7 @@ def homedir(self):
     .. versionadded:: 7.0
     """
 
-    nick_auth_password = ValidatedAttribute('nick_auth_password')
+    nick_auth_password = SecretAttribute('nick_auth_password')
     """The password to use to authenticate the bot's nick.
 
     .. seealso::
@@ -970,7 +971,7 @@ def homedir(self):
     .. versionadded:: 7.0
     """
 
-    server_auth_password = ValidatedAttribute('server_auth_password')
+    server_auth_password = SecretAttribute('server_auth_password')
     """The password to use to authenticate with the server.
 
     .. versionadded:: 7.0
diff --git a/sopel/config/types.py b/sopel/config/types.py
--- a/sopel/config/types.py
+++ b/sopel/config/types.py
@@ -26,6 +26,7 @@
 
 from __future__ import absolute_import, division, print_function, unicode_literals
 
+import getpass
 import os.path
 import re
 import sys
@@ -83,7 +84,7 @@ def configure_setting(self, name, prompt, default=NO_DEFAULT):
         not the attribute's default.
         """
         clazz = getattr(self.__class__, name)
-        if default is NO_DEFAULT:
+        if default is NO_DEFAULT and not clazz.is_secret:
             try:
                 default = getattr(self, name)
             except AttributeError:
@@ -111,27 +112,60 @@ class BaseValidated(object):
     :param str name: the attribute name to use in the config file
     :param default: the value to be returned if the setting has no value
                     (optional; defaults to :obj:`None`)
-    :type default: str
+    :type default: mixed
+    :param bool is_secret: tell if the attribute is secret/a password
+                           (optional; defaults to ``False``)
 
     ``default`` also can be set to :const:`sopel.config.types.NO_DEFAULT`, if
     the value *must* be configured by the user (i.e. there is no suitable
     default value). Trying to read an empty ``NO_DEFAULT`` value will raise
     :class:`AttributeError`.
     """
-    def __init__(self, name, default=None):
+    def __init__(self, name, default=None, is_secret=False):
         self.name = name
+        """Name of the attribute."""
         self.default = default
+        """Default value for this attribute.
 
-    def configure(self, prompt, default, parent, section_name):
+        If not specified, the attribute's default value will be ``None``.
         """
-        With the ``prompt`` and ``default``, parse and return a value from
-        terminal.
+        self.is_secret = bool(is_secret)
+        """Tell if the attribute is secret/a password.
+
+        The default value is ``False`` (not secret).
+
+        Sopel's configuration can contain passwords, secret keys, and other
+        private information that must be treated as sensitive data. Such
+        options should be marked as "secret" with this attribute.
+        """
+
+    def configure(self, prompt, default, parent, section_name):
+        """Parse and return a value from user's input.
+
+        :param str prompt: text to show the user
+        :param mixed default: default value used if no input given
+        :param parent: usually the parent Config object
+        :type parent: :class:`~sopel.config.Config`
+        :param str section_name: the name of the containing section
+
+        This method displays the ``prompt`` and waits for user's input on the
+        terminal. If no input is provided (i.e. the user just presses "Enter"),
+        the ``default`` value is returned instead.
+
+        If :attr:`.is_secret` is ``True``, the input will be hidden, using the
+        built-in :func:`~getpass.getpass` function.
         """
         if default is not NO_DEFAULT and default is not None:
             prompt = '{} [{}]'.format(prompt, default)
-        value = get_input(prompt + ' ')
+
+        if self.is_secret:
+            value = getpass.getpass(prompt + ' (hidden input) ')
+        else:
+            value = get_input(prompt + ' ')
+
         if not value and default is NO_DEFAULT:
             raise ValueError("You must provide a value for this option.")
+
         value = value or default
         return self.parse(value)
 
@@ -208,9 +242,17 @@ class ValidatedAttribute(BaseValidated):
                       that can be written to the config file safely (optional;
                       defaults to :class:`str`)
     :type serialize: :term:`function`
+    :param bool is_secret: ``True`` when the attribute should be considered
+                           a secret, like a password (default to ``False``)
     """
-    def __init__(self, name, parse=None, serialize=None, default=None):
-        super(ValidatedAttribute, self).__init__(name, default=default)
+    def __init__(self,
+                 name,
+                 parse=None,
+                 serialize=None,
+                 default=None,
+                 is_secret=False):
+        super(ValidatedAttribute, self).__init__(
+            name, default=default, is_secret=is_secret)
         if parse == bool:
             parse = _parse_boolean
             if not serialize or serialize == bool:
@@ -235,16 +277,28 @@ def parse(self, value):
         return value
 
     def configure(self, prompt, default, parent, section_name):
-        """
-        With the ``prompt`` and ``default``, parse and return a value from
-        terminal.
-        """
         if self.parse == _parse_boolean:
             prompt += ' (y/n)'
             default = 'y' if default else 'n'
         return super(ValidatedAttribute, self).configure(prompt, default, parent, section_name)
 
 
+class SecretAttribute(ValidatedAttribute):
+    """A config attribute containing a value which must be kept secret.
+
+    This attribute is always considered to be secret/sensitive data, but
+    otherwise behaves like other any option.
+    """
+    def __init__(self, name, parse=None, serialize=None, default=None):
+        super(SecretAttribute, self).__init__(
+            name,
+            parse=parse,
+            serialize=serialize,
+            default=default,
+            is_secret=True,
+        )
+
+
 class ListAttribute(BaseValidated):
     """A config attribute containing a list of string values.
 
@@ -401,10 +455,6 @@ def serialize_item(self, item):
         return item
 
     def configure(self, prompt, default, parent, section_name):
-        """
-        With the ``prompt`` and ``default``, parse and return a value from
-        terminal.
-        """
         each_prompt = '?'
         if isinstance(prompt, tuple):
             each_prompt = prompt[1]
@@ -520,10 +570,6 @@ def __set__(self, instance, value):
         instance._parser.set(instance._section_name, self.name, value)
 
     def configure(self, prompt, default, parent, section_name):
-        """
-        With the ``prompt`` and ``default``, parse and return a value from
-        terminal.
-        """
         if default is not NO_DEFAULT and default is not None:
             prompt = '{} [{}]'.format(prompt, default)
         value = get_input(prompt + ' ')
diff --git a/sopel/modules/admin.py b/sopel/modules/admin.py
--- a/sopel/modules/admin.py
+++ b/sopel/modules/admin.py
@@ -325,12 +325,22 @@ def set_config(bot, trigger):
         bot.say(exc.args[1])
         return
 
+    # Get a descriptor class for the option if it's a static section
+    descriptor = getattr(section.__class__, option) if static_sec else None
+
     # Display current value if no value is given
     if not value:
-        if option.endswith("password") or option.endswith("pass"):
+        value = getattr(section, option)
+
+        if descriptor is not None:
+            if getattr(descriptor, 'is_secret', False):
+                # Keep secret option as secret
+                value = "(secret value censored)"
+        elif option.endswith("password") or option.endswith("pass"):
+            # Fallback to guessing if secret, for backward compatiblity
+            # TODO: consider a deprecation warning when loading settings
             value = "(password censored)"
-        else:
-            value = getattr(section, option)
+
         bot.reply("%s.%s = %s (%s)" % (section_name, option, value, type(value).__name__))
         return
 
@@ -342,8 +352,7 @@ def set_config(bot, trigger):
         return
 
     # Otherwise, set the value to one given
-    if static_sec:
-        descriptor = getattr(section.__class__, option)
+    if descriptor is not None:
         try:
             if isinstance(descriptor, FilenameAttribute):
                 value = descriptor.parse(value, bot.config, descriptor)


Test patch: