Skip to content

Commit 9c90991

Browse files
authored
SG-19308 bundles certifi (#235)
* We're bundling certifi to give a more consistent certificate behaviour. We're not actually using the certifi `where()` functionality to get the path to the certificates however, as it can give the wrong path when certifi can be found else where in the `sys.path`. So we are manually pathing to the certificates provided with certifi. * Removed the rimu tests, as this site is no longer hosted.
1 parent 0c85f85 commit 9c90991

File tree

8 files changed

+4843
-67
lines changed

8 files changed

+4843
-67
lines changed

.travis.yml

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ language: python
1414
# and 3.7 is not supported in trusty. For now just use trusty to test 2.6, and use
1515
# the more modern xenial for 2.7 and 3.7.
1616

17-
# Additionally, we'll test on multiple sites. To accomplish this, we store the
18-
# secrets for the second site in a set of additional environment variables,
19-
# and copy those into the standard environment variables when running on the
20-
# second site.
2117
matrix:
2218
include:
2319

@@ -32,16 +28,6 @@ matrix:
3228
env:
3329
- RUN_FLAKE=false
3430

35-
# Python 2.6, Server #2
36-
- dist: trusty
37-
python: "2.6"
38-
# flake8 does not support python 2.6, so only run it on 2.7+
39-
env:
40-
- RUN_FLAKE=false
41-
- SG_SERVER_URL=$SG_SERVER_URL_2
42-
- SG_API_KEY=$SG_API_KEY_2
43-
- SG_HUMAN_PASSWORD=$SG_HUMAN_PASSWORD_2
44-
4531
# Test python 2.7 and 3.7 on Xenial.
4632

4733
# Python 2.7, Server #1
@@ -50,29 +36,11 @@ matrix:
5036
env:
5137
- RUN_FLAKE=true
5238

53-
# Python 2.7, Server #2
54-
- dist: xenial
55-
python: "2.7"
56-
env:
57-
- RUN_FLAKE=true
58-
- SG_SERVER_URL=$SG_SERVER_URL_2
59-
- SG_API_KEY=$SG_API_KEY_2
60-
- SG_HUMAN_PASSWORD=$SG_HUMAN_PASSWORD_2
61-
62-
# Python 3.7, Server #1
63-
- dist: xenial
64-
python: "3.7"
65-
env:
66-
- RUN_FLAKE=true
67-
6839
# Python 3.7, Server #1
6940
- dist: xenial
7041
python: "3.7"
7142
env:
7243
- RUN_FLAKE=true
73-
- SG_SERVER_URL=$SG_SERVER_URL_2
74-
- SG_API_KEY=$SG_API_KEY_2
75-
- SG_HUMAN_PASSWORD=$SG_HUMAN_PASSWORD_2
7644

7745
# command to install dependencies
7846
install:

shotgun_api3/lib/certifi/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .core import contents, where
2+
3+
__version__ = "2020.06.20"

shotgun_api3/lib/certifi/__main__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import argparse
2+
3+
from certifi import contents, where
4+
5+
parser = argparse.ArgumentParser()
6+
parser.add_argument("-c", "--contents", action="store_true")
7+
args = parser.parse_args()
8+
9+
if args.contents:
10+
print(contents())
11+
else:
12+
print(where())

shotgun_api3/lib/certifi/cacert.pem

Lines changed: 4620 additions & 0 deletions
Large diffs are not rendered by default.

shotgun_api3/lib/certifi/core.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
certifi.py
5+
~~~~~~~~~~
6+
7+
This module returns the installation location of cacert.pem or its contents.
8+
"""
9+
import os
10+
11+
try:
12+
from importlib.resources import path as get_path, read_text
13+
14+
_CACERT_CTX = None
15+
_CACERT_PATH = None
16+
17+
def where():
18+
# This is slightly terrible, but we want to delay extracting the file
19+
# in cases where we're inside of a zipimport situation until someone
20+
# actually calls where(), but we don't want to re-extract the file
21+
# on every call of where(), so we'll do it once then store it in a
22+
# global variable.
23+
global _CACERT_CTX
24+
global _CACERT_PATH
25+
if _CACERT_PATH is None:
26+
# This is slightly janky, the importlib.resources API wants you to
27+
# manage the cleanup of this file, so it doesn't actually return a
28+
# path, it returns a context manager that will give you the path
29+
# when you enter it and will do any cleanup when you leave it. In
30+
# the common case of not needing a temporary file, it will just
31+
# return the file system location and the __exit__() is a no-op.
32+
#
33+
# We also have to hold onto the actual context manager, because
34+
# it will do the cleanup whenever it gets garbage collected, so
35+
# we will also store that at the global level as well.
36+
_CACERT_CTX = get_path("certifi", "cacert.pem")
37+
_CACERT_PATH = str(_CACERT_CTX.__enter__())
38+
39+
return _CACERT_PATH
40+
41+
42+
except ImportError:
43+
# This fallback will work for Python versions prior to 3.7 that lack the
44+
# importlib.resources module but relies on the existing `where` function
45+
# so won't address issues with environments like PyOxidizer that don't set
46+
# __file__ on modules.
47+
def read_text(_module, _path, encoding="ascii"):
48+
with open(where(), "r", encoding=encoding) as data:
49+
return data.read()
50+
51+
# If we don't have importlib.resources, then we will just do the old logic
52+
# of assuming we're on the filesystem and munge the path directly.
53+
def where():
54+
f = os.path.dirname(__file__)
55+
56+
return os.path.join(f, "cacert.pem")
57+
58+
59+
def contents():
60+
return read_text("certifi", "cacert.pem", encoding="ascii")

shotgun_api3/lib/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@
2929
# This file is unused. It is left there so Github can warn us is a CVE is
3030
# released for our dependencies.
3131
httplib2==0.18.0
32-
six==1.12.0
32+
six==1.13.0
33+
certifi==2020.06.20

shotgun_api3/shotgun.py

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -643,38 +643,7 @@ def __init__(self,
643643

644644
self._connection = None
645645

646-
# The following lines of code allow to tell the API where to look for
647-
# certificate authorities certificates (we will be referring to these
648-
# as CAC from now on). Here's how the Python API interacts with those.
649-
#
650-
# Auth and CRUD operations
651-
# ========================
652-
# These operations are executed with httplib2. httplib2 ships with a
653-
# list of CACs instead of asking Python's ssl module for them.
654-
#
655-
# Upload/Downloads
656-
# ================
657-
# These operations are executed using urllib2. urllib2 asks a Python
658-
# module called `ssl` for CACs. On Windows, ssl searches for CACs in
659-
# the Windows Certificate Store. On Linux/macOS, it asks the OpenSSL
660-
# library linked with Python for CACs. Depending on how Python was
661-
# compiled for a given DCC, Python may be linked against the OpenSSL
662-
# from the OS or a copy of OpenSSL distributed with the DCC. This
663-
# impacts which versions of the certificates are available to Python,
664-
# as an OS level OpenSSL will be aware of system wide certificates that
665-
# have been added, while an OpenSSL that comes with a DCC is likely
666-
# bundling a list of certificates that get update with each release and
667-
# no not contain system wide certificates.
668-
#
669-
# Using custom CACs
670-
# =================
671-
# When a user requires a non-standard CAC, the SHOTGUN_API_CACERTS
672-
# environment variable allows to provide an alternate location for
673-
# the CACs.
674-
if ca_certs is not None:
675-
self.__ca_certs = ca_certs
676-
else:
677-
self.__ca_certs = os.environ.get("SHOTGUN_API_CACERTS")
646+
self.__ca_certs = self._get_certs_file(ca_certs)
678647

679648
self.base_url = (base_url or "").lower()
680649
self.config.set_server_params(self.base_url)
@@ -3249,6 +3218,66 @@ def _build_opener(self, handler):
32493218
handlers.append(handler)
32503219
return urllib.request.build_opener(*handlers)
32513220

3221+
@classmethod
3222+
def _get_certs_file(cls, ca_certs):
3223+
"""
3224+
The following method tells the API where to look for
3225+
certificate authorities certificates (we will be referring to these
3226+
as CAC from now on). Here's how the Python API interacts with those.
3227+
3228+
Auth and CRUD operations
3229+
========================
3230+
These operations are executed with httplib2. httplib2 ships with a
3231+
list of CACs instead of asking Python's ssl module for them.
3232+
3233+
Upload/Downloads
3234+
================
3235+
These operations are executed using urllib2. urllib2 asks a Python
3236+
module called `ssl` for CACs. We have bundled certifi with the API
3237+
so that we can be sure the certs are correct at the time of the API
3238+
release. This does however mean when the certs change we must update
3239+
the API to contain the latest certifi.
3240+
This approach is preferable to not using certifi since, on Windows,
3241+
ssl searches for CACs in the Windows Certificate Store, on
3242+
Linux/macOS, it asks the OpenSSL library linked with Python for CACs.
3243+
Depending on how Python was compiled for a given DCC, Python may be
3244+
linked against the OpenSSL from the OS or a copy of OpenSSL distributed
3245+
with the DCC. This impacts which versions of the certificates are
3246+
available to Python, as an OS level OpenSSL will be aware of system
3247+
wide certificates that have been added, while an OpenSSL that comes
3248+
with a DCC is likely bundling a list of certificates that get update
3249+
with each release and may not contain system wide certificates.
3250+
3251+
Using custom CACs
3252+
=================
3253+
When a user requires a non-standard CAC, the SHOTGUN_API_CACERTS
3254+
environment variable allows to provide an alternate location for
3255+
the CACs.
3256+
3257+
:param ca_certs: A default cert can be provided
3258+
:return: The cert file path to use.
3259+
"""
3260+
if ca_certs is not None:
3261+
# certs were provided up front so use these
3262+
return ca_certs
3263+
elif "SHOTGUN_API_CACERTS" in os.environ:
3264+
return os.environ.get("SHOTGUN_API_CACERTS")
3265+
else:
3266+
# No certs have been specifically provided fallback to using the
3267+
# certs shipped with this API.
3268+
# We bundle certifi with this API so that we have a higher chance
3269+
# of using an uptodate certificate, rather than relying
3270+
# on the certs that are bundled with Python or the OS in some cases.
3271+
# However we can't use certifi.where() since that searches for the
3272+
# cacert.pem file using the sys.path and this means that if another
3273+
# copy of certifi can be found first, then it won't use ours.
3274+
# So we manually generate the path to the cert, but still use certifi
3275+
# to make it easier for updating the bundled cert with the API.
3276+
cur_dir = os.path.dirname(os.path.abspath(__file__))
3277+
# Now add the rest of the path to the cert file.
3278+
cert_file = os.path.join(cur_dir, "lib", "certifi", "cacert.pem")
3279+
return cert_file
3280+
32523281
def _turn_off_ssl_validation(self):
32533282
"""
32543283
Turn off SSL certificate validation.

tests/tests_unit.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights
1111
# not expressly granted therein are reserved by Shotgun Software Inc.
1212

13+
import os
1314
import unittest
1415
from .mock import patch
1516
import shotgun_api3 as api
1617
from shotgun_api3.shotgun import _is_mimetypes_broken
17-
from shotgun_api3.lib.six.moves import range
18+
from shotgun_api3.lib.six.moves import range, urllib
19+
from shotgun_api3.lib.httplib2 import Http, ssl_error_classes
1820

1921

2022
class TestShotgunInit(unittest.TestCase):
@@ -404,6 +406,88 @@ def test_invalid(self):
404406
self.assertRaises(api.ShotgunError, api.shotgun._translate_filters, filters, "all")
405407

406408

409+
class TestCerts(unittest.TestCase):
410+
# A dummy bad url provided by Amazon
411+
bad_url = "https://untrusted-root.badssl.com/"
412+
# A list of Amazon cert URLS, taken from here:
413+
# https://aws.amazon.com/blogs/security/how-to-prepare-for-aws-move-to-its-own-certificate-authority/
414+
test_urls = [
415+
"https://good.sca1a.amazontrust.com",
416+
"https://good.sca2a.amazontrust.com",
417+
"https://good.sca3a.amazontrust.com",
418+
"https://good.sca4a.amazontrust.com",
419+
"https://good.sca0a.amazontrust.com",
420+
]
421+
422+
def setUp(self):
423+
self.sg = api.Shotgun('http://server_path',
424+
'script_name',
425+
'api_key',
426+
connect=False)
427+
428+
# Get the location of the certs file
429+
self.certs = self.sg._get_certs_file(None)
430+
431+
def _check_url_with_sg_api_httplib2(self, url, certs):
432+
"""
433+
Given a url and the certs file, it will do a simple
434+
request and return the result.
435+
"""
436+
http = Http(ca_certs=certs)
437+
return http.request(url)
438+
439+
def _check_url_with_urllib(self, url):
440+
"""
441+
Given a url it will perform a simple request and return a result.
442+
"""
443+
# create a request using the opener generated by the SG API.
444+
# The `_build_opener` method internally should use the correct certs.
445+
opener = self.sg._build_opener(urllib.request.HTTPHandler)
446+
request = urllib.request.Request(url)
447+
return opener.open(request)
448+
449+
def test_found_correct_cert(self):
450+
"""
451+
Checks that the cert file the API is finding,
452+
(when a cert path isn't passed and the SHOTGUN_API_CACERTS
453+
isn't set), is the one bundled with this API
454+
"""
455+
# Get the path to the cert file we expect the Shotgun API to find
456+
cur_path = os.path.dirname(os.path.abspath(__file__))
457+
cert_path = os.path.normpath(
458+
os.path.join(cur_path, "..", "shotgun_api3", "lib", "certifi", "cacert.pem")
459+
)
460+
# Now ensure that the path the SG API has found is correct.
461+
self.assertEquals(cert_path, self.certs)
462+
self.assertTrue(os.path.isfile(self.certs))
463+
464+
def test_httplib(self):
465+
"""
466+
Checks that we can access the amazon urls using our bundled
467+
certificate with httplib.
468+
"""
469+
# First check that we get an error when trying to connect to a known dummy bad URL
470+
self.assertRaises(ssl_error_classes, self._check_url_with_sg_api_httplib2, self.bad_url, self.certs)
471+
472+
# Now check that the good urls connect properly using the certs
473+
for url in self.test_urls:
474+
response, message = self._check_url_with_sg_api_httplib2(url, self.certs)
475+
self.assertEquals(response["status"], "200")
476+
477+
def test_urlib(self):
478+
"""
479+
Checks that we can access the amazon urls using our bundled
480+
certificate with urllib.
481+
"""
482+
# First check that we get an error when trying to connect to a known dummy bad URL
483+
self.assertRaises(urllib.error.URLError, self._check_url_with_urllib, self.bad_url)
484+
485+
# Now check that the good urls connect properly using the certs
486+
for url in self.test_urls:
487+
response = self._check_url_with_urllib(url)
488+
assert (response is not None)
489+
490+
407491
class TestMimetypesFix(unittest.TestCase):
408492
"""
409493
Makes sure that the mimetypes fix will be imported.
@@ -430,6 +514,5 @@ def test_correct_mimetypes_imported(self):
430514
self._test_mimetypes_import("win32", 3, 0, 0, False)
431515
self._test_mimetypes_import("darwin", 2, 7, 0, False)
432516

433-
434517
if __name__ == '__main__':
435518
unittest.main()

0 commit comments

Comments
 (0)