Repository: ktbyers/netmiko

Issue:
security: prevent Secrets leaking to debug-level logs when entering device privileged mode
### TL;DR: `secret` field is leaked into logging output when level is `logging.DEBUG` or lower

Netmiko logs all channel communication at Debug level via the Python logging facility, and optionally to a file via its own `SessionLog`.
While `SessionLog` filters the secrets and passwords out, the same is not true for the logger.

When a consumer enables debug-level logging at the root level (for tracing or other purposes), this results in inadvertent commitment of secrets and passwords into the logging system.

## Reproduction

```python
import netmiko
import logging
from getpass import getpass

logging.basicConfig(level=logging.DEBUG, format='%(name)6d %(levelname)s %(message)s')

password = getpass()
with ConnectHandler(
    device_type="cisco_ios",
    host="cisco1.askbow.net",
    username="cisco_test",
    password=password,
    secret=password,
) as nc:
    nc.enable()
```

The logging output will contain raw value of the secret in a `write_channel` message.

### Expected outcome
The logging output SHOULD NOT contain the value of the secret

## Workaround
Consumers who need to capture Debug-level logs from all modules can change logging level for Netmiko for this operation to prevent the secret leaking. This results in minimal loss of debug data.

For example:
```python
with ConnectHandler(
    device_type="cisco_ios",
    host="cisco1.askbow.net",
    username="cisco_test",
    password=password,
    secret=password,
) as nc:
    logging.debug('Set netmiko logging to WARNING to prevent secret leaking')
    nc_logger = logging.getLogger('netmiko')
    base_level = nc_logger.level
    nc_logger.setLevel(logging.WARNING)
    nc.enable()
    nc_logger.setLevel(base_level)
    logging.debug('Restored netmiko logging level')
    # ...
```


Discussion:

Resolving patch:
diff --git a/netmiko/base_connection.py b/netmiko/base_connection.py
--- a/netmiko/base_connection.py
+++ b/netmiko/base_connection.py
@@ -32,6 +32,7 @@
 from os import path
 from threading import Lock
 import functools
+import logging
 
 import paramiko
 import serial
@@ -71,6 +72,19 @@
 this context. You should remove any use of delay_factor=x from this method call.\n"""
 
 
+# Logging filter for #2597
+class SecretsFilter(logging.Filter):
+    def __init__(self, no_log: Optional[Dict[Any, str]] = None) -> None:
+        self.no_log = no_log
+
+    def filter(self, record: logging.LogRecord) -> bool:
+        """Removes secrets (no_log) from messages"""
+        if self.no_log:
+            for hidden_data in self.no_log.values():
+                record.msg = record.msg.replace(hidden_data, "********")
+        return True
+
+
 def lock_channel(func: F) -> F:
     @functools.wraps(func)
     def wrapper_decorator(self: "BaseConnection", *args: Any, **kwargs: Any) -> Any:
@@ -314,17 +328,25 @@ def __init__(
         self.allow_auto_change = allow_auto_change
         self.encoding = encoding
         self.sock = sock
-
-        # Netmiko will close the session_log if we open the file
+        self.fast_cli = fast_cli
+        self._legacy_mode = _legacy_mode
+        self.global_delay_factor = global_delay_factor
+        self.global_cmd_verify = global_cmd_verify
+        if self.fast_cli and self.global_delay_factor == 1:
+            self.global_delay_factor = 0.1
         self.session_log = None
         self._session_log_close = False
-        if session_log is not None:
-            no_log = {}
-            if self.password:
-                no_log["password"] = self.password
-            if self.secret:
-                no_log["secret"] = self.secret
 
+        # prevent logging secret data
+        no_log = {}
+        if self.password:
+            no_log["password"] = self.password
+        if self.secret:
+            no_log["secret"] = self.secret
+        log.addFilter(SecretsFilter(no_log=no_log))
+
+        # Netmiko will close the session_log if we open the file
+        if session_log is not None:
             if isinstance(session_log, str):
                 # If session_log is a string, open a file corresponding to string name.
                 self.session_log = SessionLog(
@@ -366,13 +388,6 @@ def __init__(
             comm_port = check_serial_port(comm_port)
             self.serial_settings.update({"port": comm_port})
 
-        self.fast_cli = fast_cli
-        self._legacy_mode = _legacy_mode
-        self.global_delay_factor = global_delay_factor
-        self.global_cmd_verify = global_cmd_verify
-        if self.fast_cli and self.global_delay_factor == 1:
-            self.global_delay_factor = 0.1
-
         # set in set_base_prompt method
         self.base_prompt = ""
         self._session_locker = Lock()


Test patch:
diff --git a/tests/SLOG/cisco881_slog.log b/tests/SLOG/cisco881_slog.log
--- a/tests/SLOG/cisco881_slog.log
+++ b/tests/SLOG/cisco881_slog.log
@@ -1,9 +1,8 @@
-
-cisco1#terminal width 511
-cisco1#terminal length 0
-cisco1#
-cisco1#
-cisco1#show ip interface brief
+cisco1>terminal width 511
+cisco1>terminal length 0
+cisco1>
+cisco1>
+cisco1>show ip interface brief
 Interface                  IP-Address      OK? Method Status                Protocol
 FastEthernet0              unassigned      YES unset  down                  down    
 FastEthernet1              unassigned      YES unset  down                  down    
@@ -11,5 +10,5 @@ FastEthernet2              unassigned      YES unset  down                  down
 FastEthernet3              unassigned      YES unset  down                  down    
 FastEthernet4              10.220.88.20    YES NVRAM  up                    up      
 Vlan1                      unassigned      YES unset  down                  down    
-cisco1#
-cisco1#exit
+cisco1>
+cisco1>exit
diff --git a/tests/SLOG/cisco881_slog_append.log b/tests/SLOG/cisco881_slog_append.log
--- a/tests/SLOG/cisco881_slog_append.log
+++ b/tests/SLOG/cisco881_slog_append.log
@@ -1,10 +1,10 @@
 Initial file contents
 
-cisco1#terminal width 511
-cisco1#terminal length 0
-cisco1#
-cisco1#
-cisco1#show ip interface brief
+cisco1>terminal width 511
+cisco1>terminal length 0
+cisco1>
+cisco1>
+cisco1>show ip interface brief
 Interface                  IP-Address      OK? Method Status                Protocol
 FastEthernet0              unassigned      YES unset  down                  down    
 FastEthernet1              unassigned      YES unset  down                  down    
@@ -12,18 +12,19 @@ FastEthernet2              unassigned      YES unset  down                  down
 FastEthernet3              unassigned      YES unset  down                  down    
 FastEthernet4              10.220.88.20    YES NVRAM  up                    up      
 Vlan1                      unassigned      YES unset  down                  down    
-cisco1#
-cisco1#exit
-cisco1#terminal width 511
-cisco1#terminal length 0
-cisco1#
-cisco1#
+cisco1>
+cisco1>exit
+cisco1>terminal width 511
+cisco1>terminal length 0
+cisco1>
+cisco1>
 Testing password and secret replacement
 This is my password ********
 This is my secret ********
-cisco1#terminal width 511
-cisco1#terminal length 0
-cisco1#
-cisco1#
+
+cisco1>terminal width 511
+cisco1>terminal length 0
+cisco1>
+cisco1>
 Testing unicode
 😁😁
\ No newline at end of file
diff --git a/tests/SLOG/cisco881_slog_append_compare.log b/tests/SLOG/cisco881_slog_append_compare.log
--- a/tests/SLOG/cisco881_slog_append_compare.log
+++ b/tests/SLOG/cisco881_slog_append_compare.log
@@ -1,8 +1,8 @@
-cisco1#terminal width 511
-cisco1#terminal length 0
-cisco1#
-cisco1#
-cisco1#show ip interface brief
+cisco1>terminal width 511
+cisco1>terminal length 0
+cisco1>
+cisco1>
+cisco1>show ip interface brief
 Interface                  IP-Address      OK? Method Status                Protocol
 FastEthernet0              unassigned      YES unset  down                  down    
 FastEthernet1              unassigned      YES unset  down                  down    
@@ -10,5 +10,5 @@ FastEthernet2              unassigned      YES unset  down                  down
 FastEthernet3              unassigned      YES unset  down                  down    
 FastEthernet4              10.220.88.20    YES NVRAM  up                    up      
 Vlan1                      unassigned      YES unset  down                  down    
-cisco1#
-cisco1#exit
+cisco1>
+cisco1>exit
diff --git a/tests/SLOG/cisco881_slog_compare.log b/tests/SLOG/cisco881_slog_compare.log
--- a/tests/SLOG/cisco881_slog_compare.log
+++ b/tests/SLOG/cisco881_slog_compare.log
@@ -1,8 +1,8 @@
-cisco1#terminal width 511
-cisco1#terminal length 0
-cisco1#
-cisco1#
-cisco1#show ip interface brief
+cisco1>terminal width 511
+cisco1>terminal length 0
+cisco1>
+cisco1>
+cisco1>show ip interface brief
 Interface                  IP-Address      OK? Method Status                Protocol
 FastEthernet0              unassigned      YES unset  down                  down    
 FastEthernet1              unassigned      YES unset  down                  down    
@@ -10,5 +10,5 @@ FastEthernet2              unassigned      YES unset  down                  down
 FastEthernet3              unassigned      YES unset  down                  down    
 FastEthernet4              10.220.88.20    YES NVRAM  up                    up      
 Vlan1                      unassigned      YES unset  down                  down    
-cisco1#
-cisco1#exit
+cisco1>
+cisco1>exit
diff --git a/tests/SLOG/cisco881_slog_wr.log b/tests/SLOG/cisco881_slog_wr.log
--- a/tests/SLOG/cisco881_slog_wr.log
+++ b/tests/SLOG/cisco881_slog_wr.log
@@ -1,28 +1,17 @@
 terminal width 511
-
-cisco1#terminal width 511
-cisco1#terminal length 0
+cisco1>terminal width 511
+cisco1>terminal length 0
 terminal length 0
-cisco1#
+cisco1>
 
-cisco1#
+cisco1>
 
-cisco1#show foooooooo
-show foooooooo
-              ^
-% Invalid input detected at '^' marker.
+cisco1>enable
+enable
+Password: ********
 
 cisco1#
 
-cisco1#show ip interface brief
-show ip interface brief
-Interface                  IP-Address      OK? Method Status                Protocol
-FastEthernet0              unassigned      YES unset  down                  down    
-FastEthernet1              unassigned      YES unset  down                  down    
-FastEthernet2              unassigned      YES unset  down                  down    
-FastEthernet3              unassigned      YES unset  down                  down    
-FastEthernet4              10.220.88.20    YES NVRAM  up                    up      
-Vlan1                      unassigned      YES unset  down                  down    
 cisco1#
 
 cisco1#exit
diff --git a/tests/SLOG/cisco881_slog_wr_compare.log b/tests/SLOG/cisco881_slog_wr_compare.log
--- a/tests/SLOG/cisco881_slog_wr_compare.log
+++ b/tests/SLOG/cisco881_slog_wr_compare.log
@@ -1,19 +1,19 @@
 terminal width 511
-cisco1#terminal width 511
-cisco1#terminal length 0
+cisco1>terminal width 511
+cisco1>terminal length 0
 terminal length 0
-cisco1#
+cisco1>
 
-cisco1#
+cisco1>
 
-cisco1#show foooooooo
+cisco1>show foooooooo
 show foooooooo
               ^
 % Invalid input detected at '^' marker.
 
-cisco1#
+cisco1>
 
-cisco1#show ip interface brief
+cisco1>show ip interface brief
 show ip interface brief
 Interface                  IP-Address      OK? Method Status                Protocol
 FastEthernet0              unassigned      YES unset  down                  down    
@@ -22,6 +22,6 @@ FastEthernet2              unassigned      YES unset  down                  down
 FastEthernet3              unassigned      YES unset  down                  down    
 FastEthernet4              10.220.88.20    YES NVRAM  up                    up      
 Vlan1                      unassigned      YES unset  down                  down    
-cisco1#
+cisco1>
 
-cisco1#exit
+cisco1>exit
diff --git a/tests/SLOG/netmiko.log b/tests/SLOG/netmiko.log
new file mode 100644
--- /dev/null
+++ b/tests/SLOG/netmiko.log
@@ -0,0 +1,117 @@
+write_channel: b'\n'
+read_channel: 
+read_channel: 
+cisco1>
+Pattern found: (cisco1.*) 
+cisco1>
+write_channel: b'enable\n'
+read_channel: 
+read_channel: enable
+Password: 
+Pattern found: (enable) enable
+read_channel: 
+Pattern found: ((?:cisco1|ssword)) 
+Password
+write_channel: b'********\n'
+read_channel: 
+read_channel: 
+read_channel: 
+cisco1#
+Pattern found: (cisco1) : 
+cisco1
+write_channel: b'\n'
+read_channel: 
+read_channel: 
+cisco1#
+Pattern found: (cisco1.*) #
+cisco1#
+read_channel: 
+write_channel: b'\n'
+read_channel: 
+cisco1#
+read_channel: 
+read_channel: 
+write_channel: b'exit\n'
+write_channel: b'terminal width 511\n'
+read_channel: 
+read_channel: 
+cisco1>terminal width
+read_channel:  511
+cisco1>
+Pattern found: (terminal width 511) 
+cisco1>terminal width 511
+In disable_paging
+Command: terminal length 0
+
+write_channel: b'terminal length 0\n'
+read_channel: 
+read_channel: terminal lengt
+read_channel: h 0
+cisco1>
+Pattern found: (terminal\ length\ 0) 
+cisco1>terminal length 0
+
+cisco1>terminal length 0
+Exiting disable_paging
+read_channel: 
+Clear buffer detects data in the channel
+read_channel: 
+write_channel: b'\n'
+read_channel: 
+cisco1>
+read_channel: 
+[find_prompt()]: prompt is cisco1>
+write_channel: b'terminal width 511\n'
+read_channel: 
+read_channel: cisco1>terminal width
+read_channel:  511
+cisco1>
+Pattern found: (terminal width 511) cisco1>terminal width 511
+In disable_paging
+Command: terminal length 0
+
+write_channel: b'terminal length 0\n'
+read_channel: 
+read_channel: terminal lengt
+read_channel: h 0
+cisco1>
+Pattern found: (terminal\ length\ 0) 
+cisco1>terminal length 0
+
+cisco1>terminal length 0
+Exiting disable_paging
+read_channel: 
+Clear buffer detects data in the channel
+read_channel: 
+write_channel: b'\n'
+read_channel: 
+cisco1>
+read_channel: 
+[find_prompt()]: prompt is cisco1>
+read_channel: 
+read_channel: 
+write_channel: b'\n'
+read_channel: 
+cisco1>
+read_channel: 
+[find_prompt()]: prompt is cisco1>
+write_channel: b'show ip interface brief\n'
+read_channel: 
+read_channel: show ip interf
+read_channel: ace brief
+Interface                  IP-Address      OK? Method Status                Protocol
+FastEthernet0              unassigned      YES unset  down                  down    
+FastEthernet1              unassigned      YES unset  down                  down    
+FastEthernet2              unassigned      YES unset  down                  down    
+FastEthernet3              unassigned      YES unset  down                  down    
+FastEthernet4              10.220.88.20    YES NVRAM  up                    up      
+Vlan1                      unassigned      YES unset  down                  down    
+cisco1>
+Pattern found: (show\ ip\ interface\ brief) show ip interface brief
+read_channel: 
+write_channel: b'\n'
+read_channel: 
+cisco1>
+read_channel: 
+read_channel: 
+write_channel: b'exit\n'
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -89,7 +89,7 @@ def net_connect_cm(request):
     return my_prompt
 
 
-@pytest.fixture(scope="module")
+@pytest.fixture(scope="function")
 def net_connect_slog_wr(request):
     """
     Create the SSH connection to the remote device. Modify session_log init arguments.
diff --git a/tests/test_netmiko_session_log.py b/tests/test_netmiko_session_log.py
--- a/tests/test_netmiko_session_log.py
+++ b/tests/test_netmiko_session_log.py
@@ -2,6 +2,7 @@
 import time
 import hashlib
 import io
+import logging
 from netmiko import ConnectHandler
 
 
@@ -130,6 +131,37 @@ def test_session_log_secrets(device_slog):
         assert conn.secret not in session_log
 
 
+def test_logging_filter_secrets(net_connect_slog_wr):
+    """Verify logging DEBUG output does not contain password or secret."""
+
+    nc = net_connect_slog_wr
+
+    # setup logger to output to file
+    file_name = "SLOG/netmiko.log"
+    netmikologger = logging.getLogger("netmiko")
+    netmikologger.setLevel(logging.DEBUG)
+    file_handler = logging.FileHandler(file_name)
+    file_handler.setLevel(logging.DEBUG)
+    netmikologger.addHandler(file_handler)
+
+    # cleanup the log file
+    with open(file_name, "w") as f:
+        f.write("")
+
+    # run sequence
+    nc.enable()
+    time.sleep(1)
+    nc.clear_buffer()
+    nc.disconnect()
+
+    with open(file_name, "r") as f:
+        netmiko_log = f.read()
+    if nc.password:
+        assert nc.password not in netmiko_log
+    if nc.secret:
+        assert nc.secret not in netmiko_log
+
+
 def test_unicode(device_slog):
     """Verify that you can write unicode characters into the session_log."""
     conn = ConnectHandler(**device_slog)