diff --git a/.gitignore b/.gitignore index 7d0865e..5429d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +.iml *.pyc db.sqlite3 manage.py diff --git a/perimeterx/middleware.py b/perimeterx/middleware.py index e20c54d..bab9973 100644 --- a/perimeterx/middleware.py +++ b/perimeterx/middleware.py @@ -1,7 +1,7 @@ from px_logger import Logger import px_context import px_activities_client -import px_cookie +import px_cookie_validator import px_httpc import px_captcha import px_api @@ -9,7 +9,6 @@ import Cookie - class PerimeterX(object): def __init__(self, app, config=None): self.app = app @@ -22,6 +21,7 @@ def __init__(self, app, config=None): 'perimeterx_server_host': 'sapi.perimeterx.net', 'captcha_enabled': True, 'server_calls_enabled': True, + 'encryption_enabled': True, 'sensitive_headers': ['cookie', 'cookies'], 'send_page_activities': True, 'api_timeout': 1, @@ -63,26 +63,30 @@ def custom_start_response(status, headers, exc_info=None): def _verify(self, environ, start_response): logger = self.config['logger'] - ctx = px_context.build_context(environ, self.config) - - if ctx.get('module_mode') == 'inactive' or is_static_file(ctx): - logger.debug('Filter static file request. uri: ' + ctx.get('uri')) - return self.app(environ, start_response) + try: + ctx = px_context.build_context(environ, self.config) - cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE')) - if self.config.get('captcha_enabled') and cookies.get('_pxCaptcha') and cookies.get('_pxCaptcha').value: - pxCaptcha = cookies.get('_pxCaptcha').value - if px_captcha.verify(ctx, self.config, pxCaptcha): - logger.debug('User passed captcha verification. user ip: ' + ctx.get('socket_ip')) + if ctx.get('module_mode') == 'inactive' or is_static_file(ctx): + logger.debug('Filter static file request. uri: ' + ctx.get('uri')) return self.app(environ, start_response) - # PX Cookie verification - if not px_cookie.verify(ctx, self.config) and self.config.get('server_calls_enabled', True): - # Server-to-Server verification fallback - if not px_api.verify(ctx, self.config): - return self.app(environ, start_response) - - return self.handle_verification(ctx, self.config, environ, start_response) + cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE')) + if self.config.get('captcha_enabled') and cookies.get('_pxCaptcha') and cookies.get('_pxCaptcha').value: + pxCaptcha = cookies.get('_pxCaptcha').value + if px_captcha.verify(ctx, self.config, pxCaptcha): + logger.debug('User passed captcha verification. user ip: ' + ctx.get('socket_ip')) + return self.app(environ, start_response) + + # PX Cookie verification + if not px_cookie_validator.verify(ctx, self.config) and self.config.get('server_calls_enabled', True): + # Server-to-Server verification fallback + if not px_api.verify(ctx, self.config): + return self.app(environ, start_response) + + return self.handle_verification(ctx, self.config, environ, start_response) + except: + logger.error("Cought exception, passing request") + self.pass_traffic(environ, start_response, ctx) def handle_verification(self, ctx, config, environ, start_response): score = ctx.get('risk_score', -1) diff --git a/perimeterx/px_activities_client.py b/perimeterx/px_activities_client.py index 7a783cc..0c4556a 100644 --- a/perimeterx/px_activities_client.py +++ b/perimeterx/px_activities_client.py @@ -25,7 +25,6 @@ def send_activities(): def send_to_perimeterx(activity_type, ctx, config, detail): global CONFIG try: - print activity_type if not config.get('server_calls_enabled', True): return @@ -57,7 +56,6 @@ def send_to_perimeterx(activity_type, ctx, config, detail): 'vid': ctx.get('vid', ''), 'uuid': ctx.get('uuid', '') } - print 'appending' ACTIVITIES_BUFFER.append(data) except: print traceback.format_exception(*sys.exc_info()) diff --git a/perimeterx/px_api.py b/perimeterx/px_api.py index a022523..844c99d 100644 --- a/perimeterx/px_api.py +++ b/perimeterx/px_api.py @@ -4,19 +4,24 @@ def send_risk_request(ctx, config): body = prepare_risk_body(ctx, config) - return px_httpc.send('/api/v1/risk', body, config) + return px_httpc.send('/api/v2/risk', body, config) def verify(ctx, config): logger = config['logger'] + logger.debug("PxAPI[verify]") try: response = send_risk_request(ctx, config) if response: - ctx['risk_score'] = response['scores']['non_human'] + score = response['score'] + ctx['score'] = score ctx['uuid'] = response['uuid'] - if ctx['risk_score'] >= config['blocking_score']: + ctx['block_action'] = response['action'] + if score >= config['blocking_score']: + logger.debug("PxAPI[verify] block score threshold reached") ctx['block_reason'] = 's2s_high_score' + logger.debug("PxAPI[verify] S2S completed") return True else: return False @@ -27,6 +32,7 @@ def verify(ctx, config): def prepare_risk_body(ctx, config): logger = config['logger'] + logger.debug("PxAPI[send_risk_request]") body = { 'request': { 'ip': ctx.get('socket_ip'), @@ -41,7 +47,8 @@ def prepare_risk_body(ctx, config): 'http_method': ctx.get('http_method', ''), 'http_version': ctx.get('http_version', ''), 'module_version': config.get('module_version', ''), - 'risk_mode': config.get('module_mode', '') + 'risk_mode': config.get('module_mode', ''), + 'px_cookie_hmac': ctx.get('cookie_hmac', '') } } @@ -53,6 +60,7 @@ def prepare_risk_body(ctx, config): logger.debug('attaching px_cookie to request') body['additional']['px_cookie'] = ctx.get('decoded_cookie') + logger.debug("PxAPI[send_risk_request] request body: " + str(body)) return body diff --git a/perimeterx/px_constants.py b/perimeterx/px_constants.py new file mode 100644 index 0000000..dbfdf04 --- /dev/null +++ b/perimeterx/px_constants.py @@ -0,0 +1,5 @@ +PREFIX_PX_COOKIE_V1 = '_px' +PREFIX_PX_COOKIE_V3 = '_px3' + +TRANS_5C = b"".join(chr(x ^ 0x5C) for x in range(256)) +TRANS_36 = b"".join(chr(x ^ 0x36) for x in range(256)) \ No newline at end of file diff --git a/perimeterx/px_context.py b/perimeterx/px_context.py index 869ef2e..716becd 100644 --- a/perimeterx/px_context.py +++ b/perimeterx/px_context.py @@ -1,15 +1,16 @@ import Cookie +from px_constants import * def build_context(environ, config): + logger = config['logger'] headers = {} # Default values http_method = 'GET' http_version = '1.1' http_protocol = 'http://' - px_cookie = '' - uuid = '' + px_cookies = {} # IP Extraction if config.get('ip_handler'): @@ -33,8 +34,12 @@ def build_context(environ, config): http_version = protocol_split[1] cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE', '')) - if cookies.get('_px') and cookies.get('_px').value: - px_cookie = cookies.get('_px').value + cookie_keys = cookies.keys() + + for key in cookie_keys: + if key == PREFIX_PX_COOKIE_V1 or key == PREFIX_PX_COOKIE_V3: + logger.debug('Found cookie prefix:' + key) + px_cookies[key] = cookies.get(key).value user_agent = headers.get('user-agent') uri = environ.get('PATH_INFO') or '' @@ -49,6 +54,6 @@ def build_context(environ, config): 'full_url': full_url, 'uri': uri, 'hostname': hostname, - '_px': px_cookie + 'px_cookies': px_cookies } return ctx diff --git a/perimeterx/px_cookie.py b/perimeterx/px_cookie.py index c3d0043..005e967 100644 --- a/perimeterx/px_cookie.py +++ b/perimeterx/px_cookie.py @@ -1,201 +1,179 @@ +import json +from px_constants import * from Crypto.Cipher import AES from time import time import base64 import hmac import hashlib -import json -import sys, traceback +import sys +import traceback import binascii import struct -_trans_5C = b"".join(chr(x ^ 0x5C) for x in range(256)) -_trans_36 = b"".join(chr(x ^ 0x36) for x in range(256)) - - -def pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None): - """Password based key derivation function 2 (PKCS #5 v2.0) - - This Python implementations based on the hmac module about as fast - as OpenSSL's PKCS5_PBKDF2_HMAC for short passwords and much faster - for long passwords. - """ - if not isinstance(hash_name, str): - raise TypeError(hash_name) - - if not isinstance(password, (bytes, bytearray)): - password = bytes(buffer(password)) - if not isinstance(salt, (bytes, bytearray)): - salt = bytes(buffer(salt)) - - # Fast inline HMAC implementation - inner = hashlib.new(hash_name) - outer = hashlib.new(hash_name) - blocksize = getattr(inner, 'block_size', 64) - if len(password) > blocksize: - password = hashlib.new(hash_name, password).digest() - password = password + b'\x00' * (blocksize - len(password)) - inner.update(password.translate(_trans_36)) - outer.update(password.translate(_trans_5C)) - - def prf(msg, inner=inner, outer=outer): - # PBKDF2_HMAC uses the password as key. We can re-use the same - # digest objects and just update copies to skip initialization. - icpy = inner.copy() - ocpy = outer.copy() - icpy.update(msg) - ocpy.update(icpy.digest()) - return ocpy.digest() - - if iterations < 1: - raise ValueError(iterations) - if dklen is None: - dklen = outer.digest_size - if dklen < 1: - raise ValueError(dklen) - - hex_format_string = "%%0%ix" % (hashlib.new(hash_name).digest_size * 2) - - dkey = b'' - loop = 1 - while len(dkey) < dklen: - prev = prf(salt + struct.pack(b'>I', loop)) - rkey = int(binascii.hexlify(prev), 16) - for i in xrange(iterations - 1): - prev = prf(prev) - rkey ^= int(binascii.hexlify(prev), 16) - loop += 1 - dkey += binascii.unhexlify(hex_format_string % rkey) - - return dkey[:dklen] - - -def is_cookie_expired(cookie): - """ - Checks if cookie validity time expired. - :param cookie: risk object - :type cookie: dictionary - :return: Returns True if valid and False if not - :rtype: Bool - """ - now = int(round(time() * 1000)) - expire = cookie[u't'] - return now > expire - - -def is_cookie_valid(cookie, cookie_key, ctx): - """ - Checks if cookie hmac signing match the request. - :param cookie: risk object - :param cookie_key: cookie secret key - :param ctx: perimeterx request context object - :type cookie: dictionary - :type cookie_key: string - :type ctx: dictionary - :return: Returns True if valid and False if not - :rtype: Bool - """ - user_agent = ctx['user_agent'] - msg = str(cookie['t']) + str(cookie['s']['a']) + str(cookie['s']['b']) + str(cookie['u']) + str( - cookie['v']) + user_agent - - valid_digest = cookie['h'] - try: - calculated_digest = hmac.new(cookie_key, msg, hashlib.sha256).hexdigest() - except: - return False - - return valid_digest == calculated_digest - - -def decrypt_cookie(cookie_key, cookie): - """ - Decrypting the PerimeterX risk cookie using AES - :param cookie_key: cookie secret key - :param cookie: risk cookie - encrypted - :type cookie_key: string - :type cookie: string - :return: Returns decrypted value if valid and False if not - :rtype: Bool|String - """ - try: - parts = cookie.split(':', 3) - if len(parts) != 3: - return False - salt = base64.b64decode(parts[0]) - iterations = int(parts[1]) - if iterations < 1 or iterations > 10000: - return False - data = base64.b64decode(parts[2]) - dk = pbkdf2_hmac('sha256', cookie_key, salt, iterations, dklen=48) - key = dk[:32] - iv = dk[32:] - cipher = AES.new(key, AES.MODE_CBC, iv) - unpad = lambda s: s[0:-ord(s[-1])] - plaintext = unpad(cipher.decrypt(data)) - return plaintext - except: - print traceback.format_exception(*sys.exc_info()) - return False - - -def verify(ctx, config): - """ - main verification function, verifying the content of the perimeterx risk cookie if exists - :param ctx: perimeterx request context object - :param config: global configurations - :type ctx: dictionary - :type config: dictionary - :return: Returns True if verification succeeded and False if not - :rtype: Bool - """ - logger = config['logger'] - px_cookie = ctx['_px'] - try: - if not px_cookie: - logger.debug('No risk cookie on the request') - ctx['s2s_call_reason'] = 'no_cookie' + +class PxCookie: + + def __init__(self): + pass + + @staticmethod + def build_px_cookie(ctx, config): + config["logger"].debug("PxCookie[build_px_cookie]") + px_cookies = ctx['px_cookies'].keys() + + # Check that its not empty + if not px_cookies: + return None + + px_cookies.sort(reverse=True) + prefix = px_cookies[0] + if prefix == PREFIX_PX_COOKIE_V1: + config["logger"].debug("PxCookie[build_px_cookie] using cookie v1") + from px_cookie_v1 import PxCookieV1 + return PxCookieV1(ctx, config) + + if prefix == PREFIX_PX_COOKIE_V3: + config["logger"].debug("PxCookie[build_px_cookie] using cookie v3") + from px_cookie_v3 import PxCookieV3 + return PxCookieV3(ctx, config) + + def decode_cookie(self): + self.config['logger'].debug("PxCookie[decode_cookie]") + return base64.b64decode(self.raw_cookie) + + def pbkdf2_hmac(self, hash_name, password, salt, iterations, dklen=None): + """Password based key derivation function 2 (PKCS #5 v2.0) + + This Python implementations based on the hmac module about as fast + as OpenSSL's PKCS5_PBKDF2_HMAC for short passwords and much faster + for long passwords. + """ + if not isinstance(hash_name, str): + raise TypeError(hash_name) + + if not isinstance(password, (bytes, bytearray)): + password = bytes(buffer(password)) + if not isinstance(salt, (bytes, bytearray)): + salt = bytes(buffer(salt)) + + # Fast inline HMAC implementation + inner = hashlib.new(hash_name) + outer = hashlib.new(hash_name) + blocksize = getattr(inner, 'block_size', 64) + if len(password) > blocksize: + password = hashlib.new(hash_name, password).digest() + password = password + b'\x00' * (blocksize - len(password)) + inner.update(password.translate(TRANS_36)) + outer.update(password.translate(TRANS_5C)) + + def prf(msg, inner=inner, outer=outer): + # PBKDF2_HMAC uses the password as key. We can re-use the same + # digest objects and just update copies to skip initialization. + icpy = inner.copy() + ocpy = outer.copy() + icpy.update(msg) + ocpy.update(icpy.digest()) + return ocpy.digest() + + if iterations < 1: + raise ValueError(iterations) + if dklen is None: + dklen = outer.digest_size + if dklen < 1: + raise ValueError(dklen) + + hex_format_string = "%%0%ix" % (hashlib.new(hash_name).digest_size * 2) + + dkey = b'' + loop = 1 + while len(dkey) < dklen: + prev = prf(salt + struct.pack(b'>I', loop)) + rkey = int(binascii.hexlify(prev), 16) + for i in xrange(iterations - 1): + prev = prf(prev) + rkey ^= int(binascii.hexlify(prev), 16) + loop += 1 + dkey += binascii.unhexlify(hex_format_string % rkey) + + return dkey[:dklen] + + def decrypt_cookie(self): + """ + Decrypting the PerimeterX risk cookie using AES + :return: Returns decrypted value if valid and False if not + :rtype: Bool|String + """ + self.config['logger'].debug("PxCookie[decrypt_cookie]") + try: + parts = self.raw_cookie.split(':', 3) + if len(parts) != 3: + return False + salt = base64.b64decode(parts[0]) + iterations = int(parts[1]) + if iterations < 1 or iterations > 10000: + return False + data = base64.b64decode(parts[2]) + dk = self.pbkdf2_hmac('sha256', self.config['cookie_key'], salt, iterations, dklen=48) + key = dk[:32] + iv = dk[32:] + cipher = AES.new(key, AES.MODE_CBC, iv) + unpad = lambda s: s[0:-ord(s[-1])] + plaintext = unpad(cipher.decrypt(data)) + self.config['logger'].debug("PxCookie[decrypt_cookie] cookie decrypted") + return plaintext + except: + print traceback.format_exception(*sys.exc_info()) + return None + + def is_cookie_expired(self): + """ + Checks if cookie validity time expired. + :return: Returns True if valid and False if not + :rtype: Bool + """ + now = int(round(time() * 1000)) + expire = self.get_timestamp() + return now > expire + + def is_cookie_valid(self, str_to_hmac): + """ + Checks if cookie hmac signing match the request. + :return: Returns True if valid and False if not + :rtype: Bool + """ + try: + calculated_digest = hmac.new(self.config['cookie_key'], str_to_hmac, hashlib.sha256).hexdigest() + return self.get_hmac() == calculated_digest + except: + self.config["logger"].debug("failed to calculate hmac") return False - decrypted_cookie = decrypt_cookie(config['cookie_key'], px_cookie) + def deserialize(self): + self.config['logger'].debug("PxCookie[deserialize]") + if self.config.get("encryption_enabled", False): + cookie = self.decrypt_cookie() + else: + cookie = self.decode_cookie() - if not decrypted_cookie: - logger.error('Cookie decryption failed') - ctx['px_orig_cookie'] = px_cookie - ctx['s2s_call_reason'] = 'cookie_decryption_failed' + if not cookie: return False - decoded_cookie = json.loads(decrypted_cookie) - try: - decoded_cookie['s'], decoded_cookie['s']['b'], decoded_cookie['u'], decoded_cookie['t'], decoded_cookie['v'] - except: - logger.error('Cookie decryption failed') - ctx['px_orig_cookie'] = px_cookie - ctx['s2s_call_reason'] = 'cookie_decryption_failed' - return False + self.config['logger'].debug("PxCookie[deserialize] decoded cookie: " + cookie) + self.decoded_cookie = json.loads(cookie) + return self.is_cookie_format_valid() - ctx['risk_score'] = decoded_cookie['s']['b'] - ctx['uuid'] = decoded_cookie.get('u', '') - ctx['vid'] = decoded_cookie.get('v', '') - ctx['decoded_cookie'] = decoded_cookie + def is_high_score(self): + return self.get_score() >= self.config['blocking_score'] + + def get_timestamp(self): + return self.decoded_cookie['t'] + + def get_uuid(self): + return self.decoded_cookie['u'] + + def get_vid(self): + return self.decoded_cookie['v'] - if decoded_cookie['s']['b'] >= config['blocking_score']: - ctx['block_reason'] = 'cookie_high_score' - logger.debug('Cookie with high score: ' + str(ctx['risk_score'])) - return True - if is_cookie_expired(decoded_cookie): - ctx['s2s_call_reason'] = 'cookie_expired' - logger.debug('Cookie expired') - return False - if not is_cookie_valid(decoded_cookie, config['cookie_key'], ctx): - logger.debug('Cookie validation failed') - ctx['s2s_call_reason'] = 'cookie_validation_failed' - return False - logger.debug('Cookie validation passed with good score: ' + str(ctx['risk_score'])) - return True - except: - logger.debug('Cookie validation failed') - ctx['s2s_call_reason'] = 'cookie_validation_failed' - return False diff --git a/perimeterx/px_cookie_v1.py b/perimeterx/px_cookie_v1.py new file mode 100644 index 0000000..959602f --- /dev/null +++ b/perimeterx/px_cookie_v1.py @@ -0,0 +1,35 @@ +from px_cookie import PxCookie +from px_constants import * + + +class PxCookieV1(PxCookie): + + def __init__(self, ctx, config): + self.ctx = ctx + self.config = config + self.raw_cookie = ctx['px_cookies'].get(PREFIX_PX_COOKIE_V1, '') + + def get_score(self): + return self.decoded_cookie['s']['b'] + + def get_hmac(self): + return self.decoded_cookie['h'] + + def get_action(self): + return 'c' + + def is_cookie_format_valid(self): + c = self.decoded_cookie + return 't' in c and 'v' in c and 'u' in c and "s" in c and 'a' in c['s'] and 'h' in c + + def is_secured(self): + c = self.decoded_cookie + user_agent = self.ctx.get('user_agent', '') + ip = self.ctx.get('ip', '') + base_hmac = str(self.get_timestamp()) + str(c['s']['a']) + str(self.get_score()) + self.get_uuid() + self.get_vid() + hmac_with_ip = base_hmac + ip + user_agent + hmac_without_ip = base_hmac + user_agent + + return self.is_cookie_valid(hmac_without_ip) or self.is_cookie_valid(hmac_with_ip) + + diff --git a/perimeterx/px_cookie_v3.py b/perimeterx/px_cookie_v3.py new file mode 100644 index 0000000..00226a8 --- /dev/null +++ b/perimeterx/px_cookie_v3.py @@ -0,0 +1,32 @@ +from px_cookie import PxCookie +from px_constants import * + + +class PxCookieV3(PxCookie): + + def __init__(self, ctx, config): + self.ctx = ctx + self.config = config + spliced_cookie = ctx['px_cookies'].get(PREFIX_PX_COOKIE_V3, '').split(":", 1) + if spliced_cookie.count > 1: + self.hmac = spliced_cookie[0] + self.raw_cookie = spliced_cookie[1] + + def get_score(self): + return self.decoded_cookie['s'] + + def get_hmac(self): + return self.hmac + + def get_action(self): + return self.decoded_cookie['a'] + + def is_cookie_format_valid(self): + c = self.decoded_cookie; + return 't' in c and 'v' in c and 'u' in c and 's' in c and 'a' in c + + def is_secured(self): + user_agent = self.ctx.get('user_agent', '') + str_hmac = self.raw_cookie + user_agent + return self.is_cookie_valid(str_hmac) + diff --git a/perimeterx/px_cookie_validator.py b/perimeterx/px_cookie_validator.py new file mode 100644 index 0000000..2aedda6 --- /dev/null +++ b/perimeterx/px_cookie_validator.py @@ -0,0 +1,59 @@ +import traceback + + +def verify(ctx, config): + """ + main verification function, verifying the content of the perimeterx risk cookie if exists + :param ctx: perimeterx request context object + :param config: global configurations + :type ctx: dictionary + :type config: dictionary + :return: Returns True if verification succeeded and False if not + :rtype: Bool + """ + logger = config['logger'] + try: + if not ctx["px_cookies"].keys(): + logger.debug('No risk cookie on the request') + ctx['s2s_call_reason'] = 'no_cookie' + return False + + from px_cookie import PxCookie + px_cookie = PxCookie.build_px_cookie(ctx, config) + + if not px_cookie.deserialize(): + logger.error('Cookie decryption failed') + ctx['px_orig_cookie'] = px_cookie.raw_cookie + ctx['s2s_call_reason'] = 'cookie_decryption_failed' + return False + + ctx['risk_score'] = px_cookie.get_score() + ctx['uuid'] = px_cookie.get_uuid() + ctx['vid'] = px_cookie.get_vid() + ctx['decoded_cookie'] = px_cookie.decoded_cookie + ctx['cookie_hmac'] = px_cookie.get_hmac() + ctx['block_action'] = px_cookie.get_action() + + if px_cookie.is_high_score(): + ctx['block_reason'] = 'cookie_high_score' + logger.debug('Cookie with high score: ' + str(ctx['risk_score'])) + return True + + if px_cookie.is_cookie_expired(): + ctx['s2s_call_reason'] = 'cookie_expired' + logger.debug('Cookie expired') + return False + + if px_cookie.is_secured(): + logger.debug('Cookie validation failed') + ctx['s2s_call_reason'] = 'cookie_validation_failed' + return False + + logger.debug('Cookie validation passed with good score: ' + str(ctx['risk_score'])) + return True + except Exception, e: + traceback.print_exc() + logger.debug('Could not decrypt cookie, exception was thrown, decryption failed ' + e.message) + ctx['px_orig_cookie'] = px_cookie.raw_cookie + ctx['s2s_call_reason'] = 'cookie_decryption_failed' + return False diff --git a/perimeterx/px_logger.py b/perimeterx/px_logger.py index 873c34c..afac7c8 100644 --- a/perimeterx/px_logger.py +++ b/perimeterx/px_logger.py @@ -6,5 +6,9 @@ def debug(self, message): if self.debug_mode: print '[PerimeterX DEBUG]: ' + message + def info(self, message): + if self.debug_mode: + print '[PerimeterX INFO]: ' + message + def error(self, message): print '[PerimeterX ERROR]: ' + message diff --git a/setup.py b/setup.py index 57f075c..179045f 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,6 @@ download_url='https://github.com/PerimeterX/perimeterx-python-wsgi/tarball/v1.0.2', package_dir={'perimeterx': 'perimeterx'}, install_requires=[ - "pystache" + "pystache", 'Crypto' ] )