Skip to content

Commit f1bd387

Browse files
ekalininmvdbeek
andauthored
feat(certifi): optionally use certifi certificates (#394)
* Use certifi certificates if available We're using nodeenv to set up node on various HPC systems, and we already use certifi. Tools that use urllib3 work out of the box with certifi, but for urllib2 this is needed for certifi certificates to be picked up. * Add a flag to use certifi certificates Defaults to not using certifi * fix(certifi): report a missing certifi instead of silent fallback --with-certifi asks for a specific trust store, so silently falling back to the system one leaves the user with an opaque SSL error instead of "certifi is not installed". Resolve certifi once in main() and warn there. Also narrows the try/except to the import only: it used to wrap the network call as well, so an ImportError raised from inside urlopen would silently repeat the request without certifi. Building the SSL context once instead of per request drops the repeated parsing of the CA bundle. * feat(certifi): allow with_certifi to be set from a config file Every other persistent option is a Config attribute used as the argparse default, which makes it settable in ~/.nodeenvrc, tox.ini or setup.cfg and lists it in Config._dump(). with_certifi was hardcoded to False and so was reachable only from the command line, while a config default is exactly what a shared machine needs. * docs(certifi): document the --with-certifi option Adds the option to the "Other options" section and with_certifi to the configuration defaults block, which mirrors Config._dump(). Mentions the SSL_CERT_FILE alternative, which reaches the same result without the option. * chore(setup): add the certifi extra --------- Co-authored-by: mvdbeek <m.vandenbeek@gmail.com>
1 parent 41c1841 commit f1bd387

5 files changed

Lines changed: 108 additions & 0 deletions

File tree

CHANGES

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ Version [unreleased]
55
--------------------
66

77
- Added check for how `activate` is called.
8+
- Added `--with-certifi` to download packages with the certifi certificate
9+
bundle `#388 <https://github.com/ekalinin/nodeenv/pull/388>`_
810

911
Version 1.3.1
1012
-------------

README.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,16 @@ Other options
330330
``--ignore_ssl_certs``
331331
Ignore SSL certificates for package downloads. **UNSAFE - use at your own risk**.
332332

333+
``--with-certifi``
334+
Use the `certifi <https://pypi.org/project/certifi/>`_ certificate bundle for
335+
package downloads instead of the system certificate store. Useful when the
336+
system store is missing or outdated. If certifi is not installed, a warning is
337+
printed and the system store is used. Ignored when ``--ignore_ssl_certs`` is
338+
given. The same result can be achieved without this option by pointing
339+
``SSL_CERT_FILE`` at the bundle::
340+
341+
$ SSL_CERT_FILE=$(python -c 'import certifi; print(certifi.where())') nodeenv env
342+
333343
``--version``
334344
Show program version and exit.
335345

@@ -351,6 +361,7 @@ These are the available options and their defaults::
351361
make = 'make'
352362
prebuilt = True
353363
ignore_ssl_certs = False
364+
with_certifi = False
354365
mirror = None
355366

356367
Alternatives

nodeenv.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@
5959
is_CYGWIN = platform.system().startswith(('CYGWIN', 'MSYS'))
6060

6161
ignore_ssl_certs = False
62+
# SSL context backed by the certifi bundle, built once by main()
63+
# when --with-certifi is given and certifi is importable
64+
certifi_context = None
6265

6366
# ---------------------------------------------------------
6467
# Utils
@@ -101,6 +104,7 @@ class Config(object):
101104
make = 'make'
102105
prebuilt = True
103106
ignore_ssl_certs = False
107+
with_certifi = False
104108
mirror = None
105109

106110
@classmethod
@@ -368,6 +372,12 @@ def make_parser():
368372
action='store_true', default=Config.ignore_ssl_certs,
369373
help='Ignore certificates for package downloads. - UNSAFE -')
370374

375+
parser.add_argument(
376+
'--with-certifi', dest='with_certifi',
377+
action='store_true', default=Config.with_certifi,
378+
help='Use the certifi certificate bundle for package downloads, '
379+
'if certifi is installed. Ignored with --ignore_ssl_certs.')
380+
371381
parser.add_argument(
372382
metavar='DEST_DIR', dest='env_dir', nargs='?',
373383
help='Destination directory')
@@ -644,6 +654,24 @@ def download_node_src(node_url, src_dir, args):
644654
archive.extractall(src_dir, extract_list)
645655

646656

657+
def make_certifi_context():
658+
"""
659+
Build an SSL context backed by the certifi bundle.
660+
661+
Returns None if certifi is not installed, so that downloads keep
662+
using the system certificate store.
663+
"""
664+
try:
665+
import certifi
666+
except ImportError:
667+
logger.warning(
668+
'certifi is not installed, --with-certifi is ignored: '
669+
'falling back to the system certificate store')
670+
return None
671+
672+
return ssl.create_default_context(cafile=certifi.where())
673+
674+
647675
def urlopen(url):
648676
home_url = "https://github.com/ekalinin/nodeenv/"
649677
headers = {'User-Agent': 'nodeenv/%s (%s)' % (nodeenv_version, home_url)}
@@ -654,6 +682,11 @@ def urlopen(url):
654682
context = ssl.SSLContext(ssl.PROTOCOL_TLS)
655683
context.verify_mode = ssl.CERT_NONE
656684
return urllib2.urlopen(req, context=context)
685+
686+
# Use certifi certificates if they were requested and are available
687+
if certifi_context is not None:
688+
return urllib2.urlopen(req, context=certifi_context)
689+
657690
return urllib2.urlopen(req)
658691

659692
# ---------------------------------------------------------
@@ -1132,8 +1165,11 @@ def main():
11321165

11331166
global src_base_url
11341167
global ignore_ssl_certs
1168+
global certifi_context
11351169

11361170
ignore_ssl_certs = args.ignore_ssl_certs
1171+
if args.with_certifi and not ignore_ssl_certs:
1172+
certifi_context = make_certifi_context()
11371173

11381174
src_domain = None
11391175
if args.mirror:

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def read_file(file_name):
3737
author='Eugene Kalinin',
3838
author_email='e.v.kalinin@gmail.com',
3939
install_requires=[],
40+
extras_require={'certifi': ['certifi']},
4041
python_requires=(
4142
">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
4243
),

tests/nodeenv_test.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import sys
1313
import sysconfig
1414
import platform
15+
import ssl
1516
import zipfile
1617

1718
try:
@@ -1619,3 +1620,60 @@ def test_install_npm_win_zip_extraction(self):
16191620

16201621
# Verify extraction
16211622
mock_zip.extractall.assert_called_once_with(src_dir)
1623+
1624+
1625+
class TestCertifi:
1626+
"""Tests for the --with-certifi option"""
1627+
1628+
def test_urlopen_without_certifi(self):
1629+
"""No SSL context is passed when certifi is not in use"""
1630+
with mock.patch.object(nodeenv, 'ignore_ssl_certs', False), \
1631+
mock.patch.object(nodeenv, 'certifi_context', None), \
1632+
mock.patch.object(nodeenv.urllib2, 'urlopen') as m_urlopen:
1633+
nodeenv.urlopen('https://nodejs.org/dist/index.json')
1634+
1635+
assert m_urlopen.call_args[1] == {}
1636+
1637+
def test_urlopen_with_certifi(self):
1638+
"""The context built by main() is reused for every download"""
1639+
with mock.patch.object(nodeenv, 'ignore_ssl_certs', False), \
1640+
mock.patch.object(nodeenv, 'certifi_context',
1641+
mock.sentinel.certifi_context), \
1642+
mock.patch.object(nodeenv.urllib2, 'urlopen') as m_urlopen:
1643+
nodeenv.urlopen('https://nodejs.org/dist/index.json')
1644+
1645+
context = m_urlopen.call_args[1]['context']
1646+
assert context is mock.sentinel.certifi_context
1647+
1648+
def test_urlopen_ignore_ssl_certs_wins(self):
1649+
"""--ignore_ssl_certs takes precedence over --with-certifi"""
1650+
with mock.patch.object(nodeenv, 'ignore_ssl_certs', True), \
1651+
mock.patch.object(nodeenv, 'certifi_context',
1652+
mock.sentinel.certifi_context), \
1653+
mock.patch.object(nodeenv.urllib2, 'urlopen') as m_urlopen:
1654+
nodeenv.urlopen('https://nodejs.org/dist/index.json')
1655+
1656+
assert m_urlopen.call_args[1]['context'].verify_mode == ssl.CERT_NONE
1657+
1658+
def test_make_certifi_context(self):
1659+
certifi = mock.Mock()
1660+
certifi.where.return_value = '/path/to/cacert.pem'
1661+
1662+
with mock.patch.dict(sys.modules, {'certifi': certifi}), \
1663+
mock.patch.object(nodeenv.ssl,
1664+
'create_default_context') as m_context:
1665+
assert nodeenv.make_certifi_context() is m_context.return_value
1666+
1667+
m_context.assert_called_once_with(cafile='/path/to/cacert.pem')
1668+
1669+
def test_make_certifi_context_without_certifi(self):
1670+
"""A missing certifi is reported instead of silently ignored"""
1671+
with mock.patch.dict(sys.modules, {'certifi': None}), \
1672+
mock.patch.object(nodeenv.logger, 'warning') as m_warning:
1673+
assert nodeenv.make_certifi_context() is None
1674+
1675+
assert 'certifi is not installed' in m_warning.call_args[0][0]
1676+
1677+
def test_with_certifi_is_configurable(self):
1678+
"""with_certifi can be set from the config file, like other options"""
1679+
assert 'with_certifi' in nodeenv.Config._default

0 commit comments

Comments
 (0)