From 3175d142cb80751b6be98076ebfe46bb52f52103 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 17 Apr 2026 14:07:37 +1000 Subject: [PATCH 01/24] Redact secrets when logging --- pyproject.toml | 1 + source/config/__init__.py | 3 ++- source/logHandler.py | 35 ++++++++++++++++++++++++++++------- uv.lock | 15 +++++++++++++++ 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 20b86bb47a4..69e632d31cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "mdx-gh-links==0.4", "l2m4m==1.0.4", "pymdown-extensions==10.17.1", + "detect-secrets==1.5.0", ] [project.urls] diff --git a/source/config/__init__.py b/source/config/__init__.py index c9b3f600708..d59a4eb5923 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -604,7 +604,7 @@ def _loadConfig(self, fn, fileError=False): profileUpgrader.upgrade(profile, self.validator, writeProfileFunc) except Exception as e: # Log at level info to ensure that the profile is logged. - log.info("Config before schema update:\n%s" % profileCopy, exc_info=False) + log.info("Config before schema update:\n%s" % profileCopy, exc_info=False, redactSecrets=True) raise e # since profile settings are not yet imported we have to "peek" to see # if debug level logging is enabled. @@ -618,6 +618,7 @@ def _loadConfig(self, fn, fileError=False): "Config loaded (after upgrade, and in the state it will be used by NVDA):\n{0}".format( profile, ), + redactSecrets=True, ) return profile diff --git a/source/logHandler.py b/source/logHandler.py index 4e1f7a17ccb..7be4aa40ce8 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -233,15 +233,30 @@ class Logger(logging.Logger): def _log( self, - level, - msg, + level: int, + msg: str, args, - exc_info=None, - extra=None, - codepath=None, - activateLogViewer=False, - stack_info=None, + exc_info: _excInfo_t | Literal[True] | BaseException = None, + extra: dict | None = None, + codepath: str | None = None, + activateLogViewer: bool = False, + stack_info: list[traceback.FrameSummary] | bool | None = None, + redactSecrets: bool = False, ): + """Logs a message with the given severity level. + + :param level: The severity level of the log message. + :param msg: The log message, which may contain format specifiers that will be replaced by the values in `args`. + :param args: The arguments to be merged into `msg` using the `%` operator for string formatting. + :param exc_info: Exception information to be logged, defaults to None + :param extra: Additional information to be logged, defaults to None + :param codepath: The code path where the log was generated, defaults to None + :param activateLogViewer: Whether to activate the log viewer, defaults to False + :param stack_info: Stack information to be logged, defaults to None + :param redactSecrets: Whether to check for and redact secrets in the log message, defaults to False + :return: The result of the logging operation + """ + if not extra: extra = {} @@ -273,6 +288,12 @@ def _log( "".join(traceback.format_list(stack_info)).rstrip(), ) + if redactSecrets: + from detect_secrets.core.scan import scan_line + + for secret in scan_line(msg: str): + msg.replace(secret.secret_value, "****") + res = super()._log(level, msg, args, exc_info, extra) if activateLogViewer: diff --git a/uv.lock b/uv.lock index f8d4f5443f9..859e8d8105d 100644 --- a/uv.lock +++ b/uv.lock @@ -188,6 +188,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "detect-secrets" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -526,6 +539,7 @@ dependencies = [ { name = "configobj", marker = "sys_platform == 'win32'" }, { name = "crowdin-api-client", marker = "sys_platform == 'win32'" }, { name = "cryptography", marker = "sys_platform == 'win32'" }, + { name = "detect-secrets", marker = "sys_platform == 'win32'" }, { name = "fast-diff-match-patch", marker = "sys_platform == 'win32'" }, { name = "fuzzysearch", marker = "sys_platform == 'win32'" }, { name = "l2m4m", marker = "sys_platform == 'win32'" }, @@ -583,6 +597,7 @@ requires-dist = [ { name = "configobj", git = "https://github.com/DiffSK/configobj?rev=9c8a0a80c767bf8a3d6493ed01df6c351bddca42" }, { name = "crowdin-api-client", specifier = "==1.24.1" }, { name = "cryptography", specifier = "==46.0.6" }, + { name = "detect-secrets", specifier = "==1.5.0" }, { name = "fast-diff-match-patch", specifier = "==2.1.0" }, { name = "fuzzysearch", specifier = "==0.8.1" }, { name = "l2m4m", specifier = "==1.0.4" }, From e3a0deaa90f3b9e291c46458b0336809b1d1702c Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 17 Apr 2026 14:16:03 +1000 Subject: [PATCH 02/24] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Sean Budd --- source/logHandler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/logHandler.py b/source/logHandler.py index 7be4aa40ce8..1102024471b 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -291,8 +291,8 @@ def _log( if redactSecrets: from detect_secrets.core.scan import scan_line - for secret in scan_line(msg: str): - msg.replace(secret.secret_value, "****") + for secret in scan_line(msg): + msg = msg.replace(secret.secret_value, "****") res = super()._log(level, msg, args, exc_info, extra) From a0702a8eb7a7f0c68bfa5dfca5747ba49c744402 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 17 Apr 2026 16:51:57 +1000 Subject: [PATCH 03/24] fix inclusion --- source/setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/setup.py b/source/setup.py index 6b3b164dfa2..bb3cf9c9910 100755 --- a/source/setup.py +++ b/source/setup.py @@ -319,6 +319,8 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]: "mdx_truly_sane_lists", "mdx_gh_links", "pymdownx", + # Force as only import is scoped in a function. + "detect_secrets", ], "includes": [ "nvdaBuiltin", From 94c1668742b97f3a88f47d78583c34f3f01712f4 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 17 Apr 2026 17:08:58 +1000 Subject: [PATCH 04/24] fix up detection and formatting --- source/logHandler.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/source/logHandler.py b/source/logHandler.py index 1102024471b..fae4632ce28 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -290,11 +290,17 @@ def _log( if redactSecrets: from detect_secrets.core.scan import scan_line + from detect_secrets.settings import default_settings + formattedMsg = msg % args if args else msg - for secret in scan_line(msg): - msg = msg.replace(secret.secret_value, "****") + with default_settings(): + for secret in list(scan_line(formattedMsg)): + formattedMsg = formattedMsg.replace(secret.secret_value, "****") - res = super()._log(level, msg, args, exc_info, extra) + res = super()._log(level, formattedMsg, (), exc_info, extra) + + else: + res = super()._log(level, msg, args, exc_info, extra) if activateLogViewer: # Make the log text we just wrote appear in the log viewer. From 520d9bc23d2e5d0d3e910ae8026111fb0cabf6e6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:10:09 +0000 Subject: [PATCH 05/24] Pre-commit auto-fix --- source/logHandler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/logHandler.py b/source/logHandler.py index fae4632ce28..123fdd24fb7 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -291,6 +291,7 @@ def _log( if redactSecrets: from detect_secrets.core.scan import scan_line from detect_secrets.settings import default_settings + formattedMsg = msg % args if args else msg with default_settings(): From 098b3cb8ed872fb1ce6db79f0f0a1e87e4904971 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 17 Apr 2026 17:14:13 +1000 Subject: [PATCH 06/24] document changes --- user_docs/en/changes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 9ddf081eb22..f14ab52c617 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -70,6 +70,10 @@ Please refer to [the developer guide](https://download.nvaccess.org/documentatio * uv to 0.11.4. (#19548, #19908) * Requests to 2.33.0. (#19877) * cryptography to 46.0.6. (#19877) +* A new parameter `redactSecrets` has been added to logging functions e.g. `log.debug`. (#19966) + * When set to `True`, logging output will be sanitized to replace detected secrets with asterisks. + * This is set to `False` by default for performance purposes. + * It is encouraged to enable this when logging anything particularly sensitive e.g. clipboard content. * NVDA libraries built by the build system are now linked with the [/SETCOMPAT](https://learn.microsoft.com/en-us/cpp/build/reference/cetcompat) flag, improving protection against certain malware attacks. (#19435, @LeonarddeR) * Subclasses of `browseMode.BrowseModeDocumentTreeInterceptor` that support screen layout being on and off should override the `_toggleScreenLayout` method, rather than implementing `script_toggleScreenLayout` directly. (#19487) * A new method has been added to the UIA.UIA class, called `_getUIACacheablePropertyValue_handleCOMErrors`. (#19646, @Emil-18) From fb2fe7e4217a25534346a009b13391740cf884cb Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 17 Apr 2026 17:39:06 +1000 Subject: [PATCH 07/24] review suggestions --- source/logHandler.py | 21 ++++++++++++++------- source/setup.py | 2 -- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/source/logHandler.py b/source/logHandler.py index 123fdd24fb7..71bd7368e75 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -21,6 +21,7 @@ import winKernel import buildVersion from typing import ( + Any, Literal, NamedTuple, Protocol, @@ -31,6 +32,9 @@ import NVDAState from NVDAState import WritePaths +from detect_secrets.core.scan import scan_line +from detect_secrets.settings import default_settings + if TYPE_CHECKING: import extensionPoints @@ -231,12 +235,14 @@ class Logger(logging.Logger): #: @type: C{long} fragmentStart = None + secretDetectionSettings = default_settings() + def _log( self, level: int, msg: str, - args, - exc_info: _excInfo_t | Literal[True] | BaseException = None, + args: tuple[Any, ...], + exc_info: _excInfo_t | bool | BaseException = None, extra: dict | None = None, codepath: str | None = None, activateLogViewer: bool = False, @@ -289,12 +295,13 @@ def _log( ) if redactSecrets: - from detect_secrets.core.scan import scan_line - from detect_secrets.settings import default_settings - - formattedMsg = msg % args if args else msg + try: + formattedMsg = msg % args if args else msg + except Exception: + formattedMsg = msg + self.exception("Failed to format log message for secret redaction, logging unredacted exception.") - with default_settings(): + with self.secretDetectionSettings: for secret in list(scan_line(formattedMsg)): formattedMsg = formattedMsg.replace(secret.secret_value, "****") diff --git a/source/setup.py b/source/setup.py index bb3cf9c9910..6b3b164dfa2 100755 --- a/source/setup.py +++ b/source/setup.py @@ -319,8 +319,6 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]: "mdx_truly_sane_lists", "mdx_gh_links", "pymdownx", - # Force as only import is scoped in a function. - "detect_secrets", ], "includes": [ "nvdaBuiltin", From 9b266b5128cb2f74d766bb633291f9c80ae60169 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:40:10 +0000 Subject: [PATCH 08/24] Pre-commit auto-fix --- source/logHandler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/logHandler.py b/source/logHandler.py index 71bd7368e75..82f3ab11b74 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -299,7 +299,9 @@ def _log( formattedMsg = msg % args if args else msg except Exception: formattedMsg = msg - self.exception("Failed to format log message for secret redaction, logging unredacted exception.") + self.exception( + "Failed to format log message for secret redaction, logging unredacted exception." + ) with self.secretDetectionSettings: for secret in list(scan_line(formattedMsg)): From a2551f4f51a8fcf4f97c80d303720459fe7f789a Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 17 Apr 2026 17:46:07 +1000 Subject: [PATCH 09/24] add unit tests --- tests/unit/test_logHandler.py | 89 +++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/unit/test_logHandler.py diff --git a/tests/unit/test_logHandler.py b/tests/unit/test_logHandler.py new file mode 100644 index 00000000000..1437e98a6cf --- /dev/null +++ b/tests/unit/test_logHandler.py @@ -0,0 +1,89 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Unit tests for secret redaction in the logHandler module.""" + +import contextlib +import logging +import types +import unittest +from unittest import mock + +import logHandler + + +class TestLoggerSecretRedaction(unittest.TestCase): + def setUp(self): + self.logger = logHandler.Logger("testLogHandler") + self.logger.secretDetectionSettings = contextlib.nullcontext() + + def test_logWithoutRedactionPassesMessageAndArgsThrough(self): + with mock.patch.object(logging.Logger, "_log") as superLog, mock.patch.object(logHandler, "scan_line") as scanLine: + self.logger._log( + logging.INFO, + "api key %s", + ("secret-value",), + codepath="tests.unit.test_logHandler", + ) + + scanLine.assert_not_called() + superLog.assert_called_once_with( + logging.INFO, + "api key %s", + ("secret-value",), + None, + {"codepath": "tests.unit.test_logHandler"}, + ) + + def test_logWithRedactionMasksDetectedSecrets(self): + secret = types.SimpleNamespace(secret_value="secret-value") + + with mock.patch.object(logging.Logger, "_log") as superLog, mock.patch.object( + logHandler, + "scan_line", + return_value=[secret], + ) as scanLine: + self.logger._log( + logging.INFO, + "api key %s and again %s", + ("secret-value", "secret-value"), + codepath="tests.unit.test_logHandler", + redactSecrets=True, + ) + + scanLine.assert_called_once_with("api key secret-value and again secret-value") + superLog.assert_called_once_with( + logging.INFO, + "api key **** and again ****", + (), + None, + {"codepath": "tests.unit.test_logHandler"}, + ) + + def test_logWithRedactionFallsBackWhenFormattingFails(self): + with mock.patch.object(logging.Logger, "_log") as superLog, mock.patch.object( + logHandler, + "scan_line", + return_value=[], + ) as scanLine, mock.patch.object(self.logger, "exception") as logException: + self.logger._log( + logging.INFO, + "expected int %d", + ("not-an-int",), + codepath="tests.unit.test_logHandler", + redactSecrets=True, + ) + + logException.assert_called_once_with( + "Failed to format log message for secret redaction, logging unredacted exception.", + ) + scanLine.assert_called_once_with("expected int %d") + superLog.assert_called_once_with( + logging.INFO, + "expected int %d", + (), + None, + {"codepath": "tests.unit.test_logHandler"}, + ) From c15a54695a5d5db70e17131ea037fb5831d267b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:47:24 +0000 Subject: [PATCH 10/24] Pre-commit auto-fix --- source/logHandler.py | 2 +- tests/unit/test_logHandler.py | 32 +++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/source/logHandler.py b/source/logHandler.py index 82f3ab11b74..86b8b49b5a5 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -300,7 +300,7 @@ def _log( except Exception: formattedMsg = msg self.exception( - "Failed to format log message for secret redaction, logging unredacted exception." + "Failed to format log message for secret redaction, logging unredacted exception.", ) with self.secretDetectionSettings: diff --git a/tests/unit/test_logHandler.py b/tests/unit/test_logHandler.py index 1437e98a6cf..10b23c43cd1 100644 --- a/tests/unit/test_logHandler.py +++ b/tests/unit/test_logHandler.py @@ -20,7 +20,10 @@ def setUp(self): self.logger.secretDetectionSettings = contextlib.nullcontext() def test_logWithoutRedactionPassesMessageAndArgsThrough(self): - with mock.patch.object(logging.Logger, "_log") as superLog, mock.patch.object(logHandler, "scan_line") as scanLine: + with ( + mock.patch.object(logging.Logger, "_log") as superLog, + mock.patch.object(logHandler, "scan_line") as scanLine, + ): self.logger._log( logging.INFO, "api key %s", @@ -40,11 +43,14 @@ def test_logWithoutRedactionPassesMessageAndArgsThrough(self): def test_logWithRedactionMasksDetectedSecrets(self): secret = types.SimpleNamespace(secret_value="secret-value") - with mock.patch.object(logging.Logger, "_log") as superLog, mock.patch.object( - logHandler, - "scan_line", - return_value=[secret], - ) as scanLine: + with ( + mock.patch.object(logging.Logger, "_log") as superLog, + mock.patch.object( + logHandler, + "scan_line", + return_value=[secret], + ) as scanLine, + ): self.logger._log( logging.INFO, "api key %s and again %s", @@ -63,11 +69,15 @@ def test_logWithRedactionMasksDetectedSecrets(self): ) def test_logWithRedactionFallsBackWhenFormattingFails(self): - with mock.patch.object(logging.Logger, "_log") as superLog, mock.patch.object( - logHandler, - "scan_line", - return_value=[], - ) as scanLine, mock.patch.object(self.logger, "exception") as logException: + with ( + mock.patch.object(logging.Logger, "_log") as superLog, + mock.patch.object( + logHandler, + "scan_line", + return_value=[], + ) as scanLine, + mock.patch.object(self.logger, "exception") as logException, + ): self.logger._log( logging.INFO, "expected int %d", From f58c1f6d09f053c8f2da1cde78c7514ee0dfa7a2 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 20 Apr 2026 19:57:46 +1000 Subject: [PATCH 11/24] fix detect_secrets inclusion --- source/logHandler.py | 4 +- source/monkeyPatches/certifiMonkeyPatches.py | 35 +++++++++++++ source/setup.py | 12 ++++- tests/unit/test_logHandler.py | 39 +++++++++++++- tests/unit/test_monkeyPatches.py | 53 ++++++++++++++++++++ 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 source/monkeyPatches/certifiMonkeyPatches.py create mode 100644 tests/unit/test_monkeyPatches.py diff --git a/source/logHandler.py b/source/logHandler.py index 86b8b49b5a5..3212d306fe2 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -235,8 +235,6 @@ class Logger(logging.Logger): #: @type: C{long} fragmentStart = None - secretDetectionSettings = default_settings() - def _log( self, level: int, @@ -303,7 +301,7 @@ def _log( "Failed to format log message for secret redaction, logging unredacted exception.", ) - with self.secretDetectionSettings: + with default_settings(): for secret in list(scan_line(formattedMsg)): formattedMsg = formattedMsg.replace(secret.secret_value, "****") diff --git a/source/monkeyPatches/certifiMonkeyPatches.py b/source/monkeyPatches/certifiMonkeyPatches.py new file mode 100644 index 00000000000..6dbcdadd159 --- /dev/null +++ b/source/monkeyPatches/certifiMonkeyPatches.py @@ -0,0 +1,35 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +import os +import sys + + +def _getBundledCaCertPath() -> str: + return os.path.join(os.path.dirname(sys.executable), "cacert.pem") + + +def apply() -> None: + """Make certifi use the bundled CA bundle in frozen builds.""" + if getattr(sys, "frozen", None) is None: + return + bundledCaCertPath = _getBundledCaCertPath() + if not os.path.isfile(bundledCaCertPath): + return + + import certifi + import certifi.core + + def where() -> str: + return bundledCaCertPath + + def contents() -> str: + with open(bundledCaCertPath, "r", encoding="ascii") as certFile: + return certFile.read() + + certifi.where = where + certifi.contents = contents + certifi.core.where = where + certifi.core.contents = contents diff --git a/source/setup.py b/source/setup.py index 6b3b164dfa2..10e4ab18ff1 100755 --- a/source/setup.py +++ b/source/setup.py @@ -288,8 +288,6 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]: "winxptheme", # numpy is an optional dependency of comtypes but we don't require it. "numpy", - # multiprocessing isn't going to work in a frozen environment - "multiprocessing", "concurrent.futures.process", # Tomli is part of Python 3.11+ as Tomlib, but is imported as tomli by cryptography, which causes an infinite loop in py2exe "tomli", @@ -302,6 +300,16 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]: "NVDAObjects.JAB", "NVDAObjects.UIA", "NVDAObjects.window", + # detect-secrets loads plugins and filters dynamically using pkgutil/importlib, + # so the relevant packages must be bundled explicitly for frozen builds. + "detect_secrets", + "detect_secrets.core", + "detect_secrets.core.plugins", + "detect_secrets.filters", + "detect_secrets.filters.gibberish", + "detect_secrets.plugins", + "detect_secrets.transformers", + "detect_secrets.util", "virtualBuffers", "appModules", "comInterfaces", diff --git a/tests/unit/test_logHandler.py b/tests/unit/test_logHandler.py index 10b23c43cd1..d69814609fe 100644 --- a/tests/unit/test_logHandler.py +++ b/tests/unit/test_logHandler.py @@ -5,7 +5,6 @@ """Unit tests for secret redaction in the logHandler module.""" -import contextlib import logging import types import unittest @@ -17,7 +16,6 @@ class TestLoggerSecretRedaction(unittest.TestCase): def setUp(self): self.logger = logHandler.Logger("testLogHandler") - self.logger.secretDetectionSettings = contextlib.nullcontext() def test_logWithoutRedactionPassesMessageAndArgsThrough(self): with ( @@ -97,3 +95,40 @@ def test_logWithRedactionFallsBackWhenFormattingFails(self): None, {"codepath": "tests.unit.test_logHandler"}, ) + + def test_logWithRealDetectSecretsMasksHash(self): + with mock.patch.object(logging.Logger, "_log") as superLog: + self.logger._log( + logging.INFO, + "Config loaded: %s", + ("{'key': '86851a5bab3f33abc2858eca0922c34c34c38f0a'}",), + codepath="tests.unit.test_logHandler", + redactSecrets=True, + ) + + loggedMsg = superLog.call_args.args[1] + self.assertIn("****", loggedMsg) + self.assertNotIn("86851a5bab3f33abc2858eca0922c34c34c38f0a", loggedMsg) + + def test_logWithRealDetectSecretsCanRedactMultipleMessages(self): + with mock.patch.object(logging.Logger, "_log") as superLog: + self.logger._log( + logging.INFO, + "first %s", + ("f7dc081e446d6975e462c6aacc4e84cced45e6e5",), + codepath="tests.unit.test_logHandler", + redactSecrets=True, + ) + self.logger._log( + logging.INFO, + "second %s", + ("86851a5bab3f33abc2858eca0922c34c34c38f0a",), + codepath="tests.unit.test_logHandler", + redactSecrets=True, + ) + + loggedMessages = [call.args[1] for call in superLog.call_args_list] + self.assertEqual(len(loggedMessages), 2) + for loggedMsg in loggedMessages: + self.assertIn("****", loggedMsg) + self.assertNotIn("86851a5bab3f33abc2858eca0922c34c34c38f0a", loggedMsg) diff --git a/tests/unit/test_monkeyPatches.py b/tests/unit/test_monkeyPatches.py new file mode 100644 index 00000000000..00d3f8509e3 --- /dev/null +++ b/tests/unit/test_monkeyPatches.py @@ -0,0 +1,53 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +import certifi +import certifi.core +from monkeyPatches import certifiMonkeyPatches + + +class TestCertifiMonkeyPatches(unittest.TestCase): + def setUp(self): + self._originalCertifiWhere = certifi.where + self._originalCertifiContents = certifi.contents + self._originalCertifiCoreWhere = certifi.core.where + self._originalCertifiCoreContents = certifi.core.contents + + def tearDown(self): + certifi.where = self._originalCertifiWhere + certifi.contents = self._originalCertifiContents + certifi.core.where = self._originalCertifiCoreWhere + certifi.core.contents = self._originalCertifiCoreContents + + def test_applyDoesNothingWhenNotFrozen(self): + with patch.object(sys, "frozen", new=None, create=True): + certifiMonkeyPatches.apply() + + self.assertIs(certifi.where, self._originalCertifiWhere) + self.assertIs(certifi.core.where, self._originalCertifiCoreWhere) + + def test_applyUsesBundledCaCertInFrozenBuild(self): + with tempfile.TemporaryDirectory() as tempDir: + bundledCertPath = os.path.join(tempDir, "cacert.pem") + with open(bundledCertPath, "w", encoding="ascii") as certFile: + certFile.write("TEST CERT") + fakeExecutablePath = os.path.join(tempDir, "nvda_noUIAccess.exe") + + with ( + patch.object(sys, "frozen", new="windows_exe", create=True), + patch.object(sys, "executable", new=fakeExecutablePath), + ): + certifiMonkeyPatches.apply() + + self.assertEqual(certifi.where(), bundledCertPath) + self.assertEqual(certifi.core.where(), bundledCertPath) + self.assertEqual(certifi.contents(), "TEST CERT") + self.assertEqual(certifi.core.contents(), "TEST CERT") From cb353110b9d02bd5953e0f9f1a601e6f99747858 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 20 Apr 2026 23:12:33 +1000 Subject: [PATCH 12/24] fix up --- source/logHandler.py | 9 ++-- source/monkeyPatches/certifiMonkeyPatches.py | 35 ------------- tests/unit/test_monkeyPatches.py | 53 -------------------- 3 files changed, 5 insertions(+), 92 deletions(-) delete mode 100644 source/monkeyPatches/certifiMonkeyPatches.py delete mode 100644 tests/unit/test_monkeyPatches.py diff --git a/source/logHandler.py b/source/logHandler.py index 3212d306fe2..28c107471d8 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -32,8 +32,6 @@ import NVDAState from NVDAState import WritePaths -from detect_secrets.core.scan import scan_line -from detect_secrets.settings import default_settings if TYPE_CHECKING: import extensionPoints @@ -246,7 +244,7 @@ def _log( activateLogViewer: bool = False, stack_info: list[traceback.FrameSummary] | bool | None = None, redactSecrets: bool = False, - ): + ) -> Any: """Logs a message with the given severity level. :param level: The severity level of the log message. @@ -258,7 +256,7 @@ def _log( :param activateLogViewer: Whether to activate the log viewer, defaults to False :param stack_info: Stack information to be logged, defaults to None :param redactSecrets: Whether to check for and redact secrets in the log message, defaults to False - :return: The result of the logging operation + :return: The result of the logging operation (None for builtin handlers). """ if not extra: @@ -293,6 +291,9 @@ def _log( ) if redactSecrets: + from detect_secrets.core.scan import scan_line + from detect_secrets.settings import default_settings + try: formattedMsg = msg % args if args else msg except Exception: diff --git a/source/monkeyPatches/certifiMonkeyPatches.py b/source/monkeyPatches/certifiMonkeyPatches.py deleted file mode 100644 index 6dbcdadd159..00000000000 --- a/source/monkeyPatches/certifiMonkeyPatches.py +++ /dev/null @@ -1,35 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2026 NV Access Limited -# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. -# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt - -import os -import sys - - -def _getBundledCaCertPath() -> str: - return os.path.join(os.path.dirname(sys.executable), "cacert.pem") - - -def apply() -> None: - """Make certifi use the bundled CA bundle in frozen builds.""" - if getattr(sys, "frozen", None) is None: - return - bundledCaCertPath = _getBundledCaCertPath() - if not os.path.isfile(bundledCaCertPath): - return - - import certifi - import certifi.core - - def where() -> str: - return bundledCaCertPath - - def contents() -> str: - with open(bundledCaCertPath, "r", encoding="ascii") as certFile: - return certFile.read() - - certifi.where = where - certifi.contents = contents - certifi.core.where = where - certifi.core.contents = contents diff --git a/tests/unit/test_monkeyPatches.py b/tests/unit/test_monkeyPatches.py deleted file mode 100644 index 00d3f8509e3..00000000000 --- a/tests/unit/test_monkeyPatches.py +++ /dev/null @@ -1,53 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2026 NV Access Limited -# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. -# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt - -import os -import sys -import tempfile -import unittest -from unittest.mock import patch - -import certifi -import certifi.core -from monkeyPatches import certifiMonkeyPatches - - -class TestCertifiMonkeyPatches(unittest.TestCase): - def setUp(self): - self._originalCertifiWhere = certifi.where - self._originalCertifiContents = certifi.contents - self._originalCertifiCoreWhere = certifi.core.where - self._originalCertifiCoreContents = certifi.core.contents - - def tearDown(self): - certifi.where = self._originalCertifiWhere - certifi.contents = self._originalCertifiContents - certifi.core.where = self._originalCertifiCoreWhere - certifi.core.contents = self._originalCertifiCoreContents - - def test_applyDoesNothingWhenNotFrozen(self): - with patch.object(sys, "frozen", new=None, create=True): - certifiMonkeyPatches.apply() - - self.assertIs(certifi.where, self._originalCertifiWhere) - self.assertIs(certifi.core.where, self._originalCertifiCoreWhere) - - def test_applyUsesBundledCaCertInFrozenBuild(self): - with tempfile.TemporaryDirectory() as tempDir: - bundledCertPath = os.path.join(tempDir, "cacert.pem") - with open(bundledCertPath, "w", encoding="ascii") as certFile: - certFile.write("TEST CERT") - fakeExecutablePath = os.path.join(tempDir, "nvda_noUIAccess.exe") - - with ( - patch.object(sys, "frozen", new="windows_exe", create=True), - patch.object(sys, "executable", new=fakeExecutablePath), - ): - certifiMonkeyPatches.apply() - - self.assertEqual(certifi.where(), bundledCertPath) - self.assertEqual(certifi.core.where(), bundledCertPath) - self.assertEqual(certifi.contents(), "TEST CERT") - self.assertEqual(certifi.core.contents(), "TEST CERT") From bec01319544aa147af0832b7fe4fb65d43f5d52e Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 11:00:29 +1000 Subject: [PATCH 13/24] Add log level and warning --- source/argsParsing.py | 11 +++---- source/config/configFlags.py | 7 ++-- source/config/configSpec.py | 2 +- source/gui/settingsDialogs.py | 62 ++++++++++++++++++++++++++++++++--- source/logHandler.py | 10 +++--- tests/unit/test_logHandler.py | 39 ++++++++++++++++++---- user_docs/en/changes.md | 1 + user_docs/en/userGuide.md | 4 ++- 8 files changed, 110 insertions(+), 26 deletions(-) diff --git a/source/argsParsing.py b/source/argsParsing.py index a1e2ae59fab..59fe7865129 100644 --- a/source/argsParsing.py +++ b/source/argsParsing.py @@ -1,8 +1,7 @@ -# -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024 NV Access Limited, Cyrille Bougot -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. +# Copyright (C) 2024-2026 NV Access Limited, Cyrille Bougot +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt import argparse import sys @@ -108,8 +107,8 @@ def _createNVDAArgParser() -> NoConsoleOptionParser: dest="logLevel", type=int, default=0, # 0 means unspecified in command line. - choices=[10, 12, 15, 20, 100], - help="The lowest level of message logged (debug 10, input/output 12, debugwarning 15, info 20, off 100).\n" + choices=[5, 10, 12, 15, 20, 100], + help="The lowest level of message logged (secrets 5, debug 10, input/output 12, debugwarning 15, info 20, off 100).\n" "Default value is 20 (info) or the user configured setting.\n" "Logging is always disabled if secure mode is enabled.\n", ) diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 9b8fbc0ab0d..a50130af313 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -1,7 +1,7 @@ # A part of NonVisual Desktop Access (NVDA) # Copyright (C) 2022-2026 NV Access Limited, Cyrille Bougot, Cary-rowen -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt """Flags used to define the possible values for an option in the configuration. Use Flag.MEMBER.value to set a new value or compare with an option in the config; @@ -421,6 +421,7 @@ class LoggingLevel(DisplayStringIntEnum): DEBUGWARNING = Logger.DEBUGWARNING IO = Logger.IO DEBUG = Logger.DEBUG + SECRETS = Logger.SECRETS @property def _displayStringLabels(self) -> dict[int, str]: @@ -435,6 +436,8 @@ def _displayStringLabels(self) -> dict[int, str]: self.IO: _("input/output"), # Translators: One of the log levels of NVDA (the debug mode shows debug messages as NVDA runs). self.DEBUG: _("debug"), + # Translators: One of the log levels of NVDA (the secrets mode logs debug messages without redacting secrets). + self.SECRETS: _("secrets"), } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 292f7c5b50c..c6c06bb2c03 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -24,7 +24,7 @@ saveConfigurationOnExit = boolean(default=True) askToExit = boolean(default=true) playStartAndExitSounds = boolean(default=true) - #possible log levels are DEBUG, IO, DEBUGWARNING, INFO + # possible log levels are SECRETS, DEBUG, IO, DEBUGWARNING, INFO and OFF loggingLevel = string(default="INFO") showWelcomeDialogAtStartup = boolean(default=true) preventDisplayTurningOff = boolean(default=true) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 6f17f10f551..f247104c639 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -7,8 +7,8 @@ # Takuya Nishimoto, jakubl7545, Tony Malykh, Rob Meredith, # Burman's Computer and Education Ltd, hwf1324, Cary-rowen, Christopher Proß, Tianze # Neil Soiffer, Ryan McCleary, Kefas Lungu. -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt import bisect import copy @@ -6187,6 +6187,47 @@ class PrivacyAndSecuritySettingsPanel(SettingsPanel): title = _("Privacy and Security") helpId = "PrivacyAndSecuritySettings" + def _getSelectedLogLevel(self) -> LoggingLevel: + selection = self._logLevelCombo.GetSelection() + if selection == wx.NOT_FOUND: + return LoggingLevel[config.conf["general"]["loggingLevel"]] + return list(LoggingLevel)[selection] + + def _confirmLogLevelChange(self, selectedLogLevel: LoggingLevel) -> bool: + if selectedLogLevel == LoggingLevel.SECRETS: + message = _( + # Translators: Warning shown when enabling the secrets log level from NVDA settings. + "Setting the logging level to secrets will write sensitive information to the log without redaction, " + "including passwords, API keys, or other private data. " + "Only enable this temporarily if you explicitly need unredacted diagnostic logs. " + "Do you want to continue?", + ) + caption = _( + # Translators: Title of the warning dialog shown when enabling the secrets log level. + "High risk logging level", + ) + else: + message = _( + # Translators: Warning shown when enabling a logging level above info from NVDA settings. + "Setting the logging level above info may record sensitive information such as typed input, " + "speech output, or other private data in the log. " + "Only enable higher logging levels temporarily while troubleshooting. " + "Do you want to continue?", + ) + caption = _( + # Translators: Title of the warning dialog shown when enabling a risky logging level. + "Warning", + ) + return ( + gui.messageBox( + message, + caption, + wx.YES | wx.NO | wx.NO_DEFAULT | wx.ICON_WARNING, + self, + ) + == wx.YES + ) + def makeSettings(self, sizer: wx.BoxSizer): sHelper = guiHelper.BoxSizerHelper(self, sizer=sizer) @@ -6265,6 +6306,7 @@ def makeSettings(self, sizer: wx.BoxSizer): ) except StopIteration: log.debugWarning("Could not set log level list to current log level") + self._savedLogLevel = self._getSelectedLogLevel() self._allowUsageStatsCheckBox: wx.CheckBox = generalGroup.addItem( # Translators: The label of a checkbox in privacy and security settings to toggle allowing of usage stats gathering @@ -6276,6 +6318,16 @@ def makeSettings(self, sizer: wx.BoxSizer): self._allowUsageStatsCheckBox.Value = False self._allowUsageStatsCheckBox.Disable() + def isValid(self) -> bool: + if not super().isValid(): + return False + if logHandler.isLogLevelForced(): + return True + selectedLogLevel = self._getSelectedLogLevel() + if selectedLogLevel == self._savedLogLevel or selectedLogLevel <= LoggingLevel.INFO: + return True + return self._confirmLogLevelChange(selectedLogLevel) + def onDiscard(self): # Restore screen curtain state and setting to the most recently saved baseline, # in case the user enabled or disabled it without saving. @@ -6301,10 +6353,10 @@ def onSave(self): ) if not logHandler.isLogLevelForced(): - config.conf["general"]["loggingLevel"] = logging.getLevelName( - list(LoggingLevel)[self._logLevelCombo.GetSelection()], - ) + selectedLogLevel = self._getSelectedLogLevel() + config.conf["general"]["loggingLevel"] = logging.getLevelName(selectedLogLevel) logHandler.setLogLevelFromConfig() + self._savedLogLevel = selectedLogLevel if updateCheck: config.conf["update"]["allowUsageStats"] = self._allowUsageStatsCheckBox.IsChecked() diff --git a/source/logHandler.py b/source/logHandler.py index 28c107471d8..b9ccec57e02 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -1,8 +1,8 @@ # A part of NonVisual Desktop Access (NVDA) # Copyright (C) 2007-2026 NV Access Limited, Rui Batista, Joseph Lee, Leonard de Ruijter, Babbage B.V., # Accessolutions, Julien Cochuyt, Cyrille Bougot, Łukasz Golonka -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt """Utilities and classes to manage logging in NVDA""" @@ -224,6 +224,7 @@ class Logger(logging.Logger): from logging import DEBUG, INFO, WARNING, WARN, ERROR, CRITICAL # Our custom levels. + SECRETS = 5 IO = 12 DEBUGWARNING = 15 OFF = 100 @@ -290,7 +291,7 @@ def _log( "".join(traceback.format_list(stack_info)).rstrip(), ) - if redactSecrets: + if redactSecrets and self.getEffectiveLevel() != self.SECRETS: from detect_secrets.core.scan import scan_line from detect_secrets.settings import default_settings @@ -619,6 +620,7 @@ def initialize(shouldDoRemoteLogging=False): @type shouldDoRemoteLogging: bool """ global log, logHandler + logging.addLevelName(Logger.SECRETS, "SECRETS") logging.addLevelName(Logger.DEBUGWARNING, "DEBUGWARNING") logging.addLevelName(Logger.IO, "IO") logging.addLevelName(Logger.OFF, "OFF") @@ -698,7 +700,7 @@ def setLogLevelFromConfig(): level = logging.getLevelName(levelName) # The lone exception to level higher than INFO is "OFF" (100). # Setting a log level to something other than options found in the GUI is unsupported. - if level not in (log.DEBUG, log.IO, log.DEBUGWARNING, log.INFO, log.OFF): + if level not in (log.SECRETS, log.DEBUG, log.IO, log.DEBUGWARNING, log.INFO, log.OFF): log.warning("invalid setting for logging level: %s" % levelName) level = log.INFO config.conf["general"]["loggingLevel"] = logging.getLevelName(log.INFO) diff --git a/tests/unit/test_logHandler.py b/tests/unit/test_logHandler.py index d69814609fe..eb198a86929 100644 --- a/tests/unit/test_logHandler.py +++ b/tests/unit/test_logHandler.py @@ -16,11 +16,13 @@ class TestLoggerSecretRedaction(unittest.TestCase): def setUp(self): self.logger = logHandler.Logger("testLogHandler") + self.logger.setLevel(logging.NOTSET) + self.logger.parent = None def test_logWithoutRedactionPassesMessageAndArgsThrough(self): with ( mock.patch.object(logging.Logger, "_log") as superLog, - mock.patch.object(logHandler, "scan_line") as scanLine, + mock.patch("detect_secrets.core.scan.scan_line") as scanLine, ): self.logger._log( logging.INFO, @@ -43,9 +45,8 @@ def test_logWithRedactionMasksDetectedSecrets(self): with ( mock.patch.object(logging.Logger, "_log") as superLog, - mock.patch.object( - logHandler, - "scan_line", + mock.patch( + "detect_secrets.core.scan.scan_line", return_value=[secret], ) as scanLine, ): @@ -69,9 +70,8 @@ def test_logWithRedactionMasksDetectedSecrets(self): def test_logWithRedactionFallsBackWhenFormattingFails(self): with ( mock.patch.object(logging.Logger, "_log") as superLog, - mock.patch.object( - logHandler, - "scan_line", + mock.patch( + "detect_secrets.core.scan.scan_line", return_value=[], ) as scanLine, mock.patch.object(self.logger, "exception") as logException, @@ -132,3 +132,28 @@ def test_logWithRealDetectSecretsCanRedactMultipleMessages(self): for loggedMsg in loggedMessages: self.assertIn("****", loggedMsg) self.assertNotIn("86851a5bab3f33abc2858eca0922c34c34c38f0a", loggedMsg) + + def test_logWithRedactionBypassesMaskingAtSecretsLevel(self): + secret = types.SimpleNamespace(secret_value="secret-value") + self.logger.setLevel(logHandler.Logger.SECRETS) + + with ( + mock.patch.object(logging.Logger, "_log") as superLog, + mock.patch("detect_secrets.core.scan.scan_line", return_value=[secret]) as scanLine, + ): + self.logger._log( + logging.INFO, + "api key %s", + ("secret-value",), + codepath="tests.unit.test_logHandler", + redactSecrets=True, + ) + + scanLine.assert_not_called() + superLog.assert_called_once_with( + logging.INFO, + "api key %s", + ("secret-value",), + None, + {"codepath": "tests.unit.test_logHandler"}, + ) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index fbad2f6f1f4..b7e81a4c993 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -75,6 +75,7 @@ Please refer to [the developer guide](https://download.nvaccess.org/documentatio * When set to `True`, logging output will be sanitized to replace detected secrets with asterisks. * This is set to `False` by default for performance purposes. * It is encouraged to enable this when logging anything particularly sensitive e.g. clipboard content. + * Added a `SECRETS` logging level for cases where developers explicitly need debug logging without `redactSecrets` masking. (#19966) * NVDA libraries built by the build system are now linked with the [/SETCOMPAT](https://learn.microsoft.com/en-us/cpp/build/reference/cetcompat) flag, improving protection against certain malware attacks. (#19435, @LeonarddeR) * Subclasses of `browseMode.BrowseModeDocumentTreeInterceptor` that support screen layout being on and off should override the `_toggleScreenLayout` method, rather than implementing `script_toggleScreenLayout` directly. (#19487) * A new method has been added to the UIA.UIA class, called `_getUIACacheablePropertyValue_handleCOMErrors`. (#19646, @Emil-18) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 87cb1686e77..26601bde694 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2815,6 +2815,8 @@ The available logging levels are: If you are concerned about privacy, do not set the logging level to this option. * Debug: In addition to info, warning, and input/output messages, additional debug messages will be logged. Just like input/output, if you are concerned about privacy, you should not set the logging level to this option. +* Secrets: In addition to debug logging, NVDA will not redact secrets such as passwords and API keys from logs. +Only enable this temporarily when important debugging information is being redacted. ##### Allow NV Access to gather NVDA usage statistics {#GeneralSettingsGatherUsageStats} @@ -6350,7 +6352,7 @@ Following are the command line options for NVDA: |`-q` |`--quit` |Quit already running copy of NVDA| |`-k` |`--check-running` |Report whether NVDA is running via the exit code; 0 if running, 1 if not running| |`-f LOGFILENAME` |`--log-file=LOGFILENAME` |The file where log messages should be written to. Logging is always disabled if secure mode is enabled.| -|`-l LOGLEVEL` |`--log-level=LOGLEVEL` |The lowest level of message logged (debug 10, input/output 12, debug warning 15, info 20, disabled 100). Logging is always disabled if secure mode is enabled.| +|`-l LOGLEVEL` |`--log-level=LOGLEVEL` |The lowest level of message logged (secrets 5, debug 10, input/output 12, debug warning 15, info 20, disabled 100). Logging is always disabled if secure mode is enabled.| |`-c CONFIGPATH` |`--config-path=CONFIGPATH` |The path where all settings for NVDA are stored. The default value is forced if secure mode is enabled.| |`-n LANGUAGE` |`--lang=LANGUAGE` |Override the configured NVDA language. Set to "Windows" for current user default, "en" for English, etc.| |`-m` |`--minimal` |No sounds, no interface, no start message, etc.| From cdc846f2923fd8ec7ac8077a54a854fce452efc2 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 12:25:40 +1000 Subject: [PATCH 14/24] Fix behaviour --- source/config/configSpec.py | 4 ++-- source/gui/settingsDialogs.py | 29 +++++++++++++++++------------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index c6c06bb2c03..d31d666e897 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -2,8 +2,8 @@ # Copyright (C) 2006-2026 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt, # Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith, # Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka, Cary-rowen, Kefas Lungu -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt from io import StringIO from configobj import ConfigObj diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index f247104c639..0a493f4cbd1 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6318,15 +6318,9 @@ def makeSettings(self, sizer: wx.BoxSizer): self._allowUsageStatsCheckBox.Value = False self._allowUsageStatsCheckBox.Disable() - def isValid(self) -> bool: - if not super().isValid(): - return False - if logHandler.isLogLevelForced(): - return True - selectedLogLevel = self._getSelectedLogLevel() - if selectedLogLevel == self._savedLogLevel or selectedLogLevel <= LoggingLevel.INFO: - return True - return self._confirmLogLevelChange(selectedLogLevel) + def onApply(self, evt): + + super().onApply(evt) def onDiscard(self): # Restore screen curtain state and setting to the most recently saved baseline, @@ -6354,9 +6348,20 @@ def onSave(self): if not logHandler.isLogLevelForced(): selectedLogLevel = self._getSelectedLogLevel() - config.conf["general"]["loggingLevel"] = logging.getLevelName(selectedLogLevel) - logHandler.setLogLevelFromConfig() - self._savedLogLevel = selectedLogLevel + updateLogLevel = ( + selectedLogLevel != self._savedLogLevel + and ( + selectedLogLevel >= LoggingLevel.INFO + or self._confirmLogLevelChange(selectedLogLevel) + ) + ) + if not updateLogLevel: + log.debug("User cancelled log level change, keeping original control value.") + selectedLogLevel = self._savedLogLevel + else: + config.conf["general"]["loggingLevel"] = logging.getLevelName(selectedLogLevel) + logHandler.setLogLevelFromConfig() + self._savedLogLevel = selectedLogLevel if updateCheck: config.conf["update"]["allowUsageStats"] = self._allowUsageStatsCheckBox.IsChecked() From 0b52053bc0e00c0ecb82ff098d23efd432320e4d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:27:56 +0000 Subject: [PATCH 15/24] Pre-commit auto-fix --- source/gui/settingsDialogs.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 0a493f4cbd1..6462432c947 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6348,12 +6348,8 @@ def onSave(self): if not logHandler.isLogLevelForced(): selectedLogLevel = self._getSelectedLogLevel() - updateLogLevel = ( - selectedLogLevel != self._savedLogLevel - and ( - selectedLogLevel >= LoggingLevel.INFO - or self._confirmLogLevelChange(selectedLogLevel) - ) + updateLogLevel = selectedLogLevel != self._savedLogLevel and ( + selectedLogLevel >= LoggingLevel.INFO or self._confirmLogLevelChange(selectedLogLevel) ) if not updateLogLevel: log.debug("User cancelled log level change, keeping original control value.") From 31771446e71b2f56cb3b51b1716b47b77ee7706c Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 12:35:19 +1000 Subject: [PATCH 16/24] fix elif --- include/nvda-mathcat | 2 +- source/gui/settingsDialogs.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/include/nvda-mathcat b/include/nvda-mathcat index 808cbe3e7dc..ef033799558 160000 --- a/include/nvda-mathcat +++ b/include/nvda-mathcat @@ -1 +1 @@ -Subproject commit 808cbe3e7dc407dba927e38e171c85a1348f2c8e +Subproject commit ef033799558d35633b4462a59cbb4cbb5ea704f4 diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 6462432c947..fb1095f9dda 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6351,13 +6351,12 @@ def onSave(self): updateLogLevel = selectedLogLevel != self._savedLogLevel and ( selectedLogLevel >= LoggingLevel.INFO or self._confirmLogLevelChange(selectedLogLevel) ) - if not updateLogLevel: - log.debug("User cancelled log level change, keeping original control value.") - selectedLogLevel = self._savedLogLevel - else: + if updateLogLevel: config.conf["general"]["loggingLevel"] = logging.getLevelName(selectedLogLevel) logHandler.setLogLevelFromConfig() self._savedLogLevel = selectedLogLevel + else: + log.debug("User cancelled log level change, keeping original control value.") if updateCheck: config.conf["update"]["allowUsageStats"] = self._allowUsageStatsCheckBox.IsChecked() From 43d007b7ed6e6b4c97fc7757184ebd8aeea42d37 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 12:45:03 +1000 Subject: [PATCH 17/24] Add help id --- source/gui/settingsDialogs.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index fb1095f9dda..ebcf4e1808f 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -85,6 +85,7 @@ import gui import gui.contextHelp +import gui.message import screenCurtain import api import ui @@ -6218,15 +6219,15 @@ def _confirmLogLevelChange(self, selectedLogLevel: LoggingLevel) -> bool: # Translators: Title of the warning dialog shown when enabling a risky logging level. "Warning", ) - return ( - gui.messageBox( - message, - caption, - wx.YES | wx.NO | wx.NO_DEFAULT | wx.ICON_WARNING, - self, - ) - == wx.YES + dialog = gui.message.MessageDialog( + parent=self, + message=message, + title=caption, + dialogType=gui.message.DialogType.WARNING, + buttons=gui.message.DefaultButtonSet.YES_NO, + helpId="GeneralSettingsLogLevel", ) + return dialog.ShowModal() == gui.message.ReturnCode.YES def makeSettings(self, sizer: wx.BoxSizer): sHelper = guiHelper.BoxSizerHelper(self, sizer=sizer) From ccc6aa2695363df2fd80581d7b0cb32870bb717d Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 14:04:45 +1000 Subject: [PATCH 18/24] Apply suggestion from @seanbudd --- source/gui/settingsDialogs.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index ebcf4e1808f..6b324e15a64 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6319,10 +6319,6 @@ def makeSettings(self, sizer: wx.BoxSizer): self._allowUsageStatsCheckBox.Value = False self._allowUsageStatsCheckBox.Disable() - def onApply(self, evt): - - super().onApply(evt) - def onDiscard(self): # Restore screen curtain state and setting to the most recently saved baseline, # in case the user enabled or disabled it without saving. From 00eb8bfdc3f2b909b2c0ca0d6a675aae549e62d6 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 14:08:07 +1000 Subject: [PATCH 19/24] Apply suggestion from @seanbudd --- source/gui/settingsDialogs.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 6b324e15a64..da624705d3e 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6352,8 +6352,6 @@ def onSave(self): config.conf["general"]["loggingLevel"] = logging.getLevelName(selectedLogLevel) logHandler.setLogLevelFromConfig() self._savedLogLevel = selectedLogLevel - else: - log.debug("User cancelled log level change, keeping original control value.") if updateCheck: config.conf["update"]["allowUsageStats"] = self._allowUsageStatsCheckBox.IsChecked() From 8f547366d25f7f2b200388db9e9544922b7f60b4 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 14:30:03 +1000 Subject: [PATCH 20/24] restore mathcat? --- include/nvda-mathcat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/nvda-mathcat b/include/nvda-mathcat index ef033799558..808cbe3e7dc 160000 --- a/include/nvda-mathcat +++ b/include/nvda-mathcat @@ -1 +1 @@ -Subproject commit ef033799558d35633b4462a59cbb4cbb5ea704f4 +Subproject commit 808cbe3e7dc407dba927e38e171c85a1348f2c8e From 49b2043ef9c3585e459023578e94d76e336318cf Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 16:32:56 +1000 Subject: [PATCH 21/24] Update source/logHandler.py Co-authored-by: Cyrille Bougot --- source/logHandler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/logHandler.py b/source/logHandler.py index 7013ec3d32f..e7765eae832 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -291,7 +291,7 @@ def _log( "".join(traceback.format_list(stack_info)).rstrip(), ) - if redactSecrets and self.getEffectiveLevel() != self.SECRETS: + if redactSecrets and self.getEffectiveLevel() > self.SECRETS: from detect_secrets.core.scan import scan_line from detect_secrets.settings import default_settings From 3924102b3881e667e017bdcfe84c311f379c25f4 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 21 Apr 2026 16:33:09 +1000 Subject: [PATCH 22/24] Update source/logHandler.py Co-authored-by: Cyrille Bougot --- source/logHandler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/source/logHandler.py b/source/logHandler.py index e7765eae832..1620fbd80a1 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -251,12 +251,12 @@ def _log( :param level: The severity level of the log message. :param msg: The log message, which may contain format specifiers that will be replaced by the values in `args`. :param args: The arguments to be merged into `msg` using the `%` operator for string formatting. - :param exc_info: Exception information to be logged, defaults to None - :param extra: Additional information to be logged, defaults to None - :param codepath: The code path where the log was generated, defaults to None - :param activateLogViewer: Whether to activate the log viewer, defaults to False - :param stack_info: Stack information to be logged, defaults to None - :param redactSecrets: Whether to check for and redact secrets in the log message, defaults to False + :param exc_info: Exception information to be logged + :param extra: Additional information to be logged + :param codepath: The code path where the log was generated + :param activateLogViewer: Whether to activate the log viewer + :param stack_info: Stack information to be logged + :param redactSecrets: Whether to check for and redact secrets in the log message :return: The result of the logging operation (None for builtin handlers). """ From 7c982ffa272ab0abd4b369229c365da23d83ab81 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 22 Apr 2026 10:27:20 +1000 Subject: [PATCH 23/24] line end --- .github/instructions/python.instructions.md | 2 +- .github/instructions/review.instructions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md index 42ebb741a46..0cdcefe600c 100644 --- a/.github/instructions/python.instructions.md +++ b/.github/instructions/python.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: **/*.py, **/*.pyw +applyTo: **/*.py, **/*.pyw description: This file describes the Python code style for the project. --- diff --git a/.github/instructions/review.instructions.md b/.github/instructions/review.instructions.md index 9d83c8c8ae9..c203bff1b68 100644 --- a/.github/instructions/review.instructions.md +++ b/.github/instructions/review.instructions.md @@ -10,7 +10,7 @@ Use these instructions when reviewing pull requests in this repository. ## Review goals -* Prioritize correctness, positive accessibility impact, API compatibility, security and avoiding regressions. +* Prioritize correctness, positive accessibility impact, API compatibility, security and avoiding regressions. * Keep feedback specific and actionable; prefer concrete code suggestions over generic comments. * Classify issues by severity: * **Blocking**: likely bug, regression risk, security concern, API break, missing required tests/docs. From 85dab52d68949ca8128282aed8ef681c0e39fa78 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 22 Apr 2026 10:28:20 +1000 Subject: [PATCH 24/24] fix ineq --- source/logHandler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/logHandler.py b/source/logHandler.py index 1620fbd80a1..6f29cc700ba 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -291,7 +291,7 @@ def _log( "".join(traceback.format_list(stack_info)).rstrip(), ) - if redactSecrets and self.getEffectiveLevel() > self.SECRETS: + if redactSecrets and self.getEffectiveLevel() < self.SECRETS: from detect_secrets.core.scan import scan_line from detect_secrets.settings import default_settings