Skip to content

Commit 42f949f

Browse files
authored
Redact secrets when logging config and warn user when changing categories (#19966)
Closes #19465 Summary of the issue: Secrets stored in NVDA config are often unintentionally logged in debug mode by NVDA Description of user facing changes: logging will attempt to redact secrets when the developer decides to sanitise risky log messages. Added a new log level: secrets, to disable redactions for required debug logging. Added a warning whenever selecting a log level below info. Description of developer facing changes: A new redactSecrets parameter for logging, which searches for and replaces secrets in the log message. Description of development approach: Use Yelp/detect-secrets This pull request introduces secret redaction support in logging, ensuring that sensitive information is masked in log outputs when requested. A new log level is added so you can view unredacted logs if needed. Secret Redaction in Logging Added a redactSecrets parameter to the Logger._log method in logHandler.py that, when enabled, uses the detect-secrets library to scan and mask detected secrets in log messages. Updated logging calls in source/config/__init__.py to use redactSecrets=True when logging potentially sensitive configuration data. Updated the developer documentation to describe the new redactSecrets parameter and recommend its use for sensitive data. Dependency and Packaging Support Added detect-secrets as a dependency in pyproject.toml and ensured all relevant submodules are included in frozen builds for dynamic plugin loading Include multiprocessing in bundle - needed for import, seems to functionally work?
1 parent 9da9710 commit 42f949f

12 files changed

Lines changed: 312 additions & 33 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ dependencies = [
5555
# pinned due to incompatibility with py2exe
5656
# https://github.com/py2exe/py2exe/issues/241
5757
"charset-normalizer==3.4.4",
58+
# secret scanning for logging
59+
"detect-secrets==1.5.0",
5860
]
5961

6062
[project.urls]

source/argsParsing.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
# -*- coding: UTF-8 -*-
21
# A part of NonVisual Desktop Access (NVDA)
3-
# Copyright (C) 2024 NV Access Limited, Cyrille Bougot
4-
# This file is covered by the GNU General Public License.
5-
# See the file COPYING for more details.
2+
# Copyright (C) 2024-2026 NV Access Limited, Cyrille Bougot
3+
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
4+
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt
65

76
import argparse
87
import sys
@@ -108,8 +107,8 @@ def _createNVDAArgParser() -> NoConsoleOptionParser:
108107
dest="logLevel",
109108
type=int,
110109
default=0, # 0 means unspecified in command line.
111-
choices=[10, 12, 15, 20, 100],
112-
help="The lowest level of message logged (debug 10, input/output 12, debugwarning 15, info 20, off 100).\n"
110+
choices=[5, 10, 12, 15, 20, 100],
111+
help="The lowest level of message logged (secrets 5, debug 10, input/output 12, debugwarning 15, info 20, off 100).\n"
113112
"Default value is 20 (info) or the user configured setting.\n"
114113
"Logging is always disabled if secure mode is enabled.\n",
115114
)

source/config/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,13 +624,14 @@ def _loadConfig(self, fn: str | None, fileError: bool = False) -> ConfigObj:
624624
except Exception as e:
625625
if self._shouldLogConfigAtStartup(profileCopy):
626626
# We must log at info level here as the logHandler hasn't been set to log at debug level yet.
627-
log.info(f"Config before schema update:\n{profileCopy}")
627+
log.info(f"Config before schema update:\n{profileCopy}", redactSecrets=True)
628628
raise e
629629

630630
if self._shouldLogConfigAtStartup(profile):
631631
# We must log at info level here as the logHandler hasn't been set to log at debug level yet.
632632
log.info(
633633
f"Config loaded (after upgrade, and in the state it will be used by NVDA):\n{profile}",
634+
redactSecrets=True,
634635
)
635636
return profile
636637

source/config/configFlags.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# A part of NonVisual Desktop Access (NVDA)
22
# Copyright (C) 2022-2026 NV Access Limited, Cyrille Bougot, Cary-rowen
3-
# This file is covered by the GNU General Public License.
4-
# See the file COPYING for more details.
3+
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
4+
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt
55

66
"""Flags used to define the possible values for an option in the configuration.
77
Use Flag.MEMBER.value to set a new value or compare with an option in the config;
@@ -421,6 +421,7 @@ class LoggingLevel(DisplayStringIntEnum):
421421
DEBUGWARNING = Logger.DEBUGWARNING
422422
IO = Logger.IO
423423
DEBUG = Logger.DEBUG
424+
SECRETS = Logger.SECRETS
424425

425426
@property
426427
def _displayStringLabels(self) -> dict[int, str]:
@@ -435,6 +436,8 @@ def _displayStringLabels(self) -> dict[int, str]:
435436
self.IO: _("input/output"),
436437
# Translators: One of the log levels of NVDA (the debug mode shows debug messages as NVDA runs).
437438
self.DEBUG: _("debug"),
439+
# Translators: One of the log levels of NVDA (the secrets mode logs debug messages without redacting secrets).
440+
self.SECRETS: _("secrets"),
438441
}
439442

440443

source/config/configSpec.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
# Copyright (C) 2006-2026 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt,
33
# Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith,
44
# Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka, Cary-rowen, Kefas Lungu
5-
# This file is covered by the GNU General Public License.
6-
# See the file COPYING for more details.
5+
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
6+
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt
77

88
from io import StringIO
99
from configobj import ConfigObj
@@ -24,7 +24,7 @@
2424
saveConfigurationOnExit = boolean(default=True)
2525
askToExit = boolean(default=true)
2626
playStartAndExitSounds = boolean(default=true)
27-
#possible log levels are DEBUG, IO, DEBUGWARNING, INFO
27+
# possible log levels are SECRETS, DEBUG, IO, DEBUGWARNING, INFO and OFF
2828
loggingLevel = string(default="INFO")
2929
showWelcomeDialogAtStartup = boolean(default=true)
3030
preventDisplayTurningOff = boolean(default=true)

source/gui/settingsDialogs.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
# Takuya Nishimoto, jakubl7545, Tony Malykh, Rob Meredith,
88
# Burman's Computer and Education Ltd, hwf1324, Cary-rowen, Christopher Proß, Tianze
99
# Neil Soiffer, Ryan McCleary, Kefas Lungu.
10-
# This file is covered by the GNU General Public License.
11-
# See the file COPYING for more details.
10+
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
11+
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt
1212

1313
import bisect
1414
import copy
@@ -85,6 +85,7 @@
8585

8686
import gui
8787
import gui.contextHelp
88+
import gui.message
8889
import screenCurtain
8990
import api
9091
import ui
@@ -6187,6 +6188,47 @@ class PrivacyAndSecuritySettingsPanel(SettingsPanel):
61876188
title = _("Privacy and Security")
61886189
helpId = "PrivacyAndSecuritySettings"
61896190

6191+
def _getSelectedLogLevel(self) -> LoggingLevel:
6192+
selection = self._logLevelCombo.GetSelection()
6193+
if selection == wx.NOT_FOUND:
6194+
return LoggingLevel[config.conf["general"]["loggingLevel"]]
6195+
return list(LoggingLevel)[selection]
6196+
6197+
def _confirmLogLevelChange(self, selectedLogLevel: LoggingLevel) -> bool:
6198+
if selectedLogLevel == LoggingLevel.SECRETS:
6199+
message = _(
6200+
# Translators: Warning shown when enabling the secrets log level from NVDA settings.
6201+
"Setting the logging level to secrets will write sensitive information to the log without redaction, "
6202+
"including passwords, API keys, or other private data. "
6203+
"Only enable this temporarily if you explicitly need unredacted diagnostic logs. "
6204+
"Do you want to continue?",
6205+
)
6206+
caption = _(
6207+
# Translators: Title of the warning dialog shown when enabling the secrets log level.
6208+
"High risk logging level",
6209+
)
6210+
else:
6211+
message = _(
6212+
# Translators: Warning shown when enabling a logging level above info from NVDA settings.
6213+
"Setting the logging level above info may record sensitive information such as typed input, "
6214+
"speech output, or other private data in the log. "
6215+
"Only enable higher logging levels temporarily while troubleshooting. "
6216+
"Do you want to continue?",
6217+
)
6218+
caption = _(
6219+
# Translators: Title of the warning dialog shown when enabling a risky logging level.
6220+
"Warning",
6221+
)
6222+
dialog = gui.message.MessageDialog(
6223+
parent=self,
6224+
message=message,
6225+
title=caption,
6226+
dialogType=gui.message.DialogType.WARNING,
6227+
buttons=gui.message.DefaultButtonSet.YES_NO,
6228+
helpId="GeneralSettingsLogLevel",
6229+
)
6230+
return dialog.ShowModal() == gui.message.ReturnCode.YES
6231+
61906232
def makeSettings(self, sizer: wx.BoxSizer):
61916233
sHelper = guiHelper.BoxSizerHelper(self, sizer=sizer)
61926234

@@ -6265,6 +6307,7 @@ def makeSettings(self, sizer: wx.BoxSizer):
62656307
)
62666308
except StopIteration:
62676309
log.debugWarning("Could not set log level list to current log level")
6310+
self._savedLogLevel = self._getSelectedLogLevel()
62686311

62696312
self._allowUsageStatsCheckBox: wx.CheckBox = generalGroup.addItem(
62706313
# Translators: The label of a checkbox in privacy and security settings to toggle allowing of usage stats gathering
@@ -6301,10 +6344,14 @@ def onSave(self):
63016344
)
63026345

63036346
if not logHandler.isLogLevelForced():
6304-
config.conf["general"]["loggingLevel"] = logging.getLevelName(
6305-
list(LoggingLevel)[self._logLevelCombo.GetSelection()],
6347+
selectedLogLevel = self._getSelectedLogLevel()
6348+
updateLogLevel = selectedLogLevel != self._savedLogLevel and (
6349+
selectedLogLevel >= LoggingLevel.INFO or self._confirmLogLevelChange(selectedLogLevel)
63066350
)
6307-
logHandler.setLogLevelFromConfig()
6351+
if updateLogLevel:
6352+
config.conf["general"]["loggingLevel"] = logging.getLevelName(selectedLogLevel)
6353+
logHandler.setLogLevelFromConfig()
6354+
self._savedLogLevel = selectedLogLevel
63086355

63096356
if updateCheck:
63106357
config.conf["update"]["allowUsageStats"] = self._allowUsageStatsCheckBox.IsChecked()

source/logHandler.py

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# A part of NonVisual Desktop Access (NVDA)
22
# Copyright (C) 2007-2026 NV Access Limited, Rui Batista, Joseph Lee, Leonard de Ruijter, Babbage B.V.,
33
# Accessolutions, Julien Cochuyt, Cyrille Bougot, Łukasz Golonka
4-
# This file is covered by the GNU General Public License.
5-
# See the file COPYING for more details.
4+
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
5+
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt
66

77
"""Utilities and classes to manage logging in NVDA"""
88

@@ -21,6 +21,7 @@
2121
import winKernel
2222
import buildVersion
2323
from typing import (
24+
Any,
2425
Literal,
2526
NamedTuple,
2627
Protocol,
@@ -31,6 +32,7 @@
3132
import NVDAState
3233
from NVDAState import WritePaths
3334

35+
3436
if TYPE_CHECKING:
3537
import extensionPoints
3638

@@ -222,6 +224,7 @@ class Logger(logging.Logger):
222224
from logging import DEBUG, INFO, WARNING, WARN, ERROR, CRITICAL
223225

224226
# Our custom levels.
227+
SECRETS = 5
225228
IO = 12
226229
DEBUGWARNING = 15
227230
OFF = 100
@@ -233,15 +236,30 @@ class Logger(logging.Logger):
233236

234237
def _log(
235238
self,
236-
level,
237-
msg,
238-
args,
239-
exc_info=None,
240-
extra=None,
241-
codepath=None,
242-
activateLogViewer=False,
243-
stack_info=None,
244-
):
239+
level: int,
240+
msg: str,
241+
args: tuple[Any, ...],
242+
exc_info: _excInfo_t | bool | BaseException = None,
243+
extra: dict | None = None,
244+
codepath: str | None = None,
245+
activateLogViewer: bool = False,
246+
stack_info: list[traceback.FrameSummary] | bool | None = None,
247+
redactSecrets: bool = False,
248+
) -> Any:
249+
"""Logs a message with the given severity level.
250+
251+
:param level: The severity level of the log message.
252+
:param msg: The log message, which may contain format specifiers that will be replaced by the values in `args`.
253+
:param args: The arguments to be merged into `msg` using the `%` operator for string formatting.
254+
:param exc_info: Exception information to be logged
255+
:param extra: Additional information to be logged
256+
:param codepath: The code path where the log was generated
257+
:param activateLogViewer: Whether to activate the log viewer
258+
:param stack_info: Stack information to be logged
259+
:param redactSecrets: Whether to check for and redact secrets in the log message
260+
:return: The result of the logging operation (None for builtin handlers).
261+
"""
262+
245263
if not extra:
246264
extra = {}
247265

@@ -273,7 +291,26 @@ def _log(
273291
"".join(traceback.format_list(stack_info)).rstrip(),
274292
)
275293

276-
res = super()._log(level, msg, args, exc_info, extra)
294+
if redactSecrets and self.getEffectiveLevel() < self.SECRETS:
295+
from detect_secrets.core.scan import scan_line
296+
from detect_secrets.settings import default_settings
297+
298+
try:
299+
formattedMsg = msg % args if args else msg
300+
except Exception:
301+
formattedMsg = msg
302+
self.exception(
303+
"Failed to format log message for secret redaction, logging unredacted exception.",
304+
)
305+
306+
with default_settings():
307+
for secret in list(scan_line(formattedMsg)):
308+
formattedMsg = formattedMsg.replace(secret.secret_value, "****")
309+
310+
res = super()._log(level, formattedMsg, (), exc_info, extra)
311+
312+
else:
313+
res = super()._log(level, msg, args, exc_info, extra)
277314

278315
if activateLogViewer:
279316
# Make the log text we just wrote appear in the log viewer.
@@ -583,6 +620,7 @@ def initialize(shouldDoRemoteLogging=False):
583620
@type shouldDoRemoteLogging: bool
584621
"""
585622
global log, logHandler
623+
logging.addLevelName(Logger.SECRETS, "SECRETS")
586624
logging.addLevelName(Logger.DEBUGWARNING, "DEBUGWARNING")
587625
logging.addLevelName(Logger.IO, "IO")
588626
logging.addLevelName(Logger.OFF, "OFF")
@@ -661,7 +699,7 @@ def setLogLevelFromConfig():
661699
level = logging.getLevelNamesMapping().get(levelName)
662700
# The lone exception to level higher than INFO is "OFF" (100).
663701
# Setting a log level to something other than options found in the GUI is unsupported.
664-
if level is None or level not in (log.DEBUG, log.IO, log.DEBUGWARNING, log.INFO, log.OFF):
702+
if level is None or level not in (log.SECRETS, log.DEBUG, log.IO, log.DEBUGWARNING, log.INFO, log.OFF):
665703
log.warning("invalid setting for logging level: %s" % levelName)
666704
level = log.INFO
667705
config.conf["general"]["loggingLevel"] = logging.getLevelName(log.INFO)

source/setup.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,6 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]:
288288
"winxptheme",
289289
# numpy is an optional dependency of comtypes but we don't require it.
290290
"numpy",
291-
# multiprocessing isn't going to work in a frozen environment
292-
"multiprocessing",
293291
"concurrent.futures.process",
294292
# Tomli is part of Python 3.11+ as Tomlib, but is imported as tomli by cryptography, which causes an infinite loop in py2exe
295293
"tomli",
@@ -302,6 +300,16 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]:
302300
"NVDAObjects.JAB",
303301
"NVDAObjects.UIA",
304302
"NVDAObjects.window",
303+
# detect-secrets loads plugins and filters dynamically using pkgutil/importlib,
304+
# so the relevant packages must be bundled explicitly for frozen builds.
305+
"detect_secrets",
306+
"detect_secrets.core",
307+
"detect_secrets.core.plugins",
308+
"detect_secrets.filters",
309+
"detect_secrets.filters.gibberish",
310+
"detect_secrets.plugins",
311+
"detect_secrets.transformers",
312+
"detect_secrets.util",
305313
"virtualBuffers",
306314
"appModules",
307315
"comInterfaces",

0 commit comments

Comments
 (0)