diff --git a/pyproject.toml b/pyproject.toml index a962ebfbda0..3c35d289aaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,8 @@ dependencies = [ # pinned due to incompatibility with py2exe # https://github.com/py2exe/py2exe/issues/241 "charset-normalizer==3.4.4", + # secret scanning for logging + "detect-secrets==1.5.0", ] [project.urls] 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/__init__.py b/source/config/__init__.py index 579c0c7227b..754cb77c86d 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -624,13 +624,14 @@ def _loadConfig(self, fn: str | None, fileError: bool = False) -> ConfigObj: except Exception as e: if self._shouldLogConfigAtStartup(profileCopy): # We must log at info level here as the logHandler hasn't been set to log at debug level yet. - log.info(f"Config before schema update:\n{profileCopy}") + log.info(f"Config before schema update:\n{profileCopy}", redactSecrets=True) raise e if self._shouldLogConfigAtStartup(profile): # We must log at info level here as the logHandler hasn't been set to log at debug level yet. log.info( f"Config loaded (after upgrade, and in the state it will be used by NVDA):\n{profile}", + redactSecrets=True, ) return profile 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..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 @@ -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..da624705d3e 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 @@ -85,6 +85,7 @@ import gui import gui.contextHelp +import gui.message import screenCurtain import api import ui @@ -6187,6 +6188,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", + ) + 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) @@ -6265,6 +6307,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 @@ -6301,10 +6344,14 @@ def onSave(self): ) if not logHandler.isLogLevelForced(): - config.conf["general"]["loggingLevel"] = logging.getLevelName( - list(LoggingLevel)[self._logLevelCombo.GetSelection()], + selectedLogLevel = self._getSelectedLogLevel() + updateLogLevel = selectedLogLevel != self._savedLogLevel and ( + selectedLogLevel >= LoggingLevel.INFO or self._confirmLogLevelChange(selectedLogLevel) ) - logHandler.setLogLevelFromConfig() + if updateLogLevel: + 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 70b1af27b90..6f29cc700ba 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""" @@ -21,6 +21,7 @@ import winKernel import buildVersion from typing import ( + Any, Literal, NamedTuple, Protocol, @@ -31,6 +32,7 @@ import NVDAState from NVDAState import WritePaths + if TYPE_CHECKING: import extensionPoints @@ -222,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 @@ -233,15 +236,30 @@ class Logger(logging.Logger): def _log( self, - level, - msg, - args, - exc_info=None, - extra=None, - codepath=None, - activateLogViewer=False, - stack_info=None, - ): + level: int, + msg: str, + args: tuple[Any, ...], + exc_info: _excInfo_t | bool | BaseException = None, + extra: dict | None = None, + codepath: str | None = None, + 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. + :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 + :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). + """ + if not extra: extra = {} @@ -273,7 +291,26 @@ def _log( "".join(traceback.format_list(stack_info)).rstrip(), ) - res = super()._log(level, msg, args, exc_info, extra) + if redactSecrets and self.getEffectiveLevel() < self.SECRETS: + 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: + formattedMsg = msg + self.exception( + "Failed to format log message for secret redaction, logging unredacted exception.", + ) + + with default_settings(): + for secret in list(scan_line(formattedMsg)): + formattedMsg = formattedMsg.replace(secret.secret_value, "****") + + 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. @@ -583,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") @@ -661,7 +699,7 @@ def setLogLevelFromConfig(): level = logging.getLevelNamesMapping().get(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 is None or level not in (log.DEBUG, log.IO, log.DEBUGWARNING, log.INFO, log.OFF): + if level is None or 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/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 new file mode 100644 index 00000000000..eb198a86929 --- /dev/null +++ b/tests/unit/test_logHandler.py @@ -0,0 +1,159 @@ +# 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 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.setLevel(logging.NOTSET) + self.logger.parent = None + + def test_logWithoutRedactionPassesMessageAndArgsThrough(self): + with ( + mock.patch.object(logging.Logger, "_log") as superLog, + mock.patch("detect_secrets.core.scan.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( + "detect_secrets.core.scan.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( + "detect_secrets.core.scan.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"}, + ) + + 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) + + 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 f662722f521..c1c1cbf3292 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -75,6 +75,11 @@ Please refer to [the developer guide](https://download.nvaccess.org/documentatio * uv to 0.11.7. (#19548, #19908, #19968) * Requests to 2.33.0. (#19877) * cryptography to 46.0.7. (#19877, #19968) +* 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. + * 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.| diff --git a/uv.lock b/uv.lock index 92352a9c177..04516f0f439 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" @@ -527,6 +540,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'" }, @@ -585,6 +599,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.7" }, + { 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" },