diff --git a/README.rst b/README.rst index 7510c5a..f6a47b6 100644 --- a/README.rst +++ b/README.rst @@ -77,6 +77,7 @@ format. An example config file with all available options:: offline = true proxy = https://user:pass@localhost:8080 no_ssl_verify = false + ssl_certs_file = timeout = 30 hostname = machinename [projectmap] diff --git a/tests/samples/configs/has_everything.cfg b/tests/samples/configs/has_everything.cfg index 8cde60a..6d410cd 100644 --- a/tests/samples/configs/has_everything.cfg +++ b/tests/samples/configs/has_everything.cfg @@ -13,6 +13,7 @@ include_only_with_project_file = false offline = false proxy = https://user:pass@localhost:8080 no_ssl_verify = false +ssl_certs_file = timeout = abc api_url = https://localhost:0/api/v1/heartbeats hostname = fromcfgfile diff --git a/tests/samples/configs/ssl_custom_certfile.cfg b/tests/samples/configs/ssl_custom_certfile.cfg new file mode 100644 index 0000000..9215516 --- /dev/null +++ b/tests/samples/configs/ssl_custom_certfile.cfg @@ -0,0 +1,18 @@ +[settings] +verbose = true +api_key = d491a956-c8f2-44a9-98a7-987814bd71ba +log_file = /tmp/waka +hide_filenames = true +exclude = + ^COMMIT_EDITMSG$ + ^TAG_EDITMSG$ + ^/var/ + ^/etc/ +include = + .* +offline = false +proxy = https://user:pass@localhost:8080 +ssl_certs_file = /fake/ca/certs/bundle.pem +timeout = abc +api_url = https://localhost:0/api/v1/heartbeats +hostname = fromcfgfile diff --git a/tests/samples/configs/has_ssl_verify_disabled.cfg b/tests/samples/configs/ssl_verify_disabled.cfg similarity index 95% rename from tests/samples/configs/has_ssl_verify_disabled.cfg rename to tests/samples/configs/ssl_verify_disabled.cfg index 5ba2328..71194d6 100644 --- a/tests/samples/configs/has_ssl_verify_disabled.cfg +++ b/tests/samples/configs/ssl_verify_disabled.cfg @@ -13,6 +13,7 @@ include = offline = false proxy = https://user:pass@localhost:8080 no_ssl_verify = true +ssl_certs_file = timeout = abc api_url = https://localhost:0/api/v1/heartbeats hostname = fromcfgfile diff --git a/tests/samples/output/common_usage_header b/tests/samples/output/common_usage_header index 5dbd60e..4e1f70a 100644 --- a/tests/samples/output/common_usage_header +++ b/tests/samples/output/common_usage_header @@ -1,7 +1,8 @@ usage: wakatime [-h] [--entity FILE] [--key KEY] [--write] [--plugin PLUGIN] [--time time] [--lineno LINENO] [--cursorpos CURSORPOS] [--entity-type ENTITY_TYPE] [--category CATEGORY] - [--proxy PROXY] [--no-ssl-verify] [--project PROJECT] + [--proxy PROXY] [--no-ssl-verify] + [--ssl-certs-file SSL_CERTS_FILE] [--project PROJECT] [--alternate-project ALTERNATE_PROJECT] [--language LANGUAGE] [--local-file FILE] [--hostname HOSTNAME] [--disable-offline] [--hide-file-names] [--hide-project-names] [--exclude EXCLUDE] diff --git a/tests/samples/output/test_help_contents b/tests/samples/output/test_help_contents index 91ff1eb..d420bda 100644 --- a/tests/samples/output/test_help_contents +++ b/tests/samples/output/test_help_contents @@ -29,6 +29,9 @@ optional arguments: socks5://user:pass@host:port or domain\user:pass --no-ssl-verify Disables SSL certificate verification for HTTPS requests. By default, SSL certificates are verified. + --ssl-certs-file SSL_CERTS_FILE + Override the bundled Python Requests CA certs file. By + default, uses certifi for ca certs. --project PROJECT Optional project name. --alternate-project ALTERNATE_PROJECT Optional alternate project name. Auto-discovered diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 8884b08..202a48a 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -471,6 +471,33 @@ class ArgumentsTestCase(TestCase): self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(ANY, cert=None, proxies=ANY, stream=False, timeout=60, verify=False) + @log_capture() + def test_custom_ssl_certs_file_argument(self, logs): + logging.disable(logging.NOTSET) + self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = CustomResponse() + + with TemporaryDirectory() as tempdir: + entity = 'tests/samples/codefiles/emptyfile.txt' + shutil.copy(entity, os.path.join(tempdir, 'emptyfile.txt')) + entity = os.path.realpath(os.path.join(tempdir, 'emptyfile.txt')) + + certfile = '/fake/certfile.pem' + config = 'tests/samples/configs/good_config.cfg' + args = ['--file', entity, '--config', config, '--ssl-certs-file', certfile] + retval = execute(args) + self.assertEquals(retval, SUCCESS) + self.assertNothingPrinted() + self.assertNothingLogged(logs) + + self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with() + self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called() + self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY) + + self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called() + self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with() + + self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(ANY, cert=None, proxies=ANY, stream=False, timeout=60, verify=certfile) + @log_capture() def test_write_argument(self, logs): logging.disable(logging.NOTSET) diff --git a/tests/test_configs.py b/tests/test_configs.py index ff21751..a730d6c 100644 --- a/tests/test_configs.py +++ b/tests/test_configs.py @@ -663,7 +663,7 @@ class ConfigsTestCase(TestCase): shutil.copy(entity, os.path.join(tempdir, 'emptyfile.txt')) entity = os.path.realpath(os.path.join(tempdir, 'emptyfile.txt')) - config = 'tests/samples/configs/has_ssl_verify_disabled.cfg' + config = 'tests/samples/configs/ssl_verify_disabled.cfg' args = ['--file', entity, '--config', config, '--timeout', '15', '--log-file', '~/.wakatime.log'] retval = execute(args) self.assertEquals(retval, SUCCESS) @@ -674,3 +674,23 @@ class ConfigsTestCase(TestCase): self.assertHeartbeatNotSavedOffline() self.assertOfflineHeartbeatsSynced() self.assertSessionCacheSaved() + + def test_ssl_custom_ca_certs_file(self): + self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = CustomResponse() + + with TemporaryDirectory() as tempdir: + entity = 'tests/samples/codefiles/emptyfile.txt' + shutil.copy(entity, os.path.join(tempdir, 'emptyfile.txt')) + entity = os.path.realpath(os.path.join(tempdir, 'emptyfile.txt')) + + config = 'tests/samples/configs/ssl_custom_certfile.cfg' + args = ['--file', entity, '--config', config, '--timeout', '15', '--log-file', '~/.wakatime.log'] + retval = execute(args) + self.assertEquals(retval, SUCCESS) + self.assertNothingPrinted() + + self.assertHeartbeatSent(proxies=ANY, timeout=15, verify='/fake/ca/certs/bundle.pem') + + self.assertHeartbeatNotSavedOffline() + self.assertOfflineHeartbeatsSynced() + self.assertSessionCacheSaved() diff --git a/wakatime/api.py b/wakatime/api.py index a6d0b4c..a98ebeb 100644 --- a/wakatime/api.py +++ b/wakatime/api.py @@ -99,12 +99,16 @@ def send_heartbeats(heartbeats, args, configs, use_ntlm_proxy=False): should_try_ntlm = '\\' in args.proxy proxies['https'] = args.proxy + ssl_verify = not args.nosslverify + if args.ssl_certs_file and ssl_verify: + ssl_verify = args.ssl_certs_file + # send request to api response, code = None, None try: response = session.post(api_url, data=request_body, headers=headers, proxies=proxies, timeout=timeout, - verify=not args.nosslverify) + verify=ssl_verify) except RequestException: if should_try_ntlm: return send_heartbeats(heartbeats, args, configs, use_ntlm_proxy=True) diff --git a/wakatime/arguments.py b/wakatime/arguments.py index 71d80a3..d02b57a 100644 --- a/wakatime/arguments.py +++ b/wakatime/arguments.py @@ -103,6 +103,10 @@ def parse_arguments(): help='Disables SSL certificate verification for HTTPS '+ 'requests. By default, SSL certificates are ' + 'verified.') + parser.add_argument('--ssl-certs-file', dest='ssl_certs_file', + action=StoreWithoutQuotes, + help='Override the bundled Python Requests CA certs ' + + 'file. By default, uses certifi for ca certs.') parser.add_argument('--project', dest='project', action=StoreWithoutQuotes, help='Optional project name.') parser.add_argument('--alternate-project', dest='alternate_project', @@ -307,6 +311,8 @@ def parse_arguments(): 'domain\\user:pass.') if configs.has_option('settings', 'no_ssl_verify'): args.nosslverify = configs.getboolean('settings', 'no_ssl_verify') + if configs.has_option('settings', 'ssl_certs_file'): + args.ssl_certs_file = configs.get('settings', 'ssl_certs_file') if not args.verbose and configs.has_option('settings', 'verbose'): args.verbose = configs.getboolean('settings', 'verbose') if not args.verbose and configs.has_option('settings', 'debug'):