Fri, 04 Aug 2017 18:41:50 +0200
Merged the Safe Browsing extension into the default branch for further processing.
--- a/Preferences/ConfigurationPages/WebBrowserPage.ui Fri Aug 04 18:29:30 2017 +0200 +++ b/Preferences/ConfigurationPages/WebBrowserPage.ui Fri Aug 04 18:41:50 2017 +0200 @@ -7,7 +7,7 @@ <x>0</x> <y>0</y> <width>616</width> - <height>1610</height> + <height>2000</height> </rect> </property> <layout class="QVBoxLayout" name="verticalLayout_2"> @@ -479,8 +479,8 @@ <property name="title"> <string>Security</string> </property> - <layout class="QHBoxLayout" name="horizontalLayout_7"> - <item> + <layout class="QGridLayout" name="gridLayout_9"> + <item row="0" column="0"> <widget class="QCheckBox" name="xssAuditingCheckBox"> <property name="toolTip"> <string>Select to enable XSS auditing</string> @@ -494,7 +494,7 @@ </property> </widget> </item> - <item> + <item row="0" column="1"> <widget class="QCheckBox" name="insecureContentsCheckBox"> <property name="toolTip"> <string>Select to allow HTTPS pages to run JavaScript, CSS, plugins or web-sockets from HTTP URLs</string>
--- a/Preferences/__init__.py Fri Aug 04 18:29:30 2017 +0200 +++ b/Preferences/__init__.py Fri Aug 04 18:41:50 2017 +0200 @@ -1119,6 +1119,10 @@ "SessionAutoSave": True, "SessionAutoSaveInterval": 15, # interval in seconds "SessionLastActivePath": "", + # Google Safe Browsing + "SafeBrowsingEnabled": True, + "SafeBrowsingApiKey": "", # API key + "SafeBrowsingFilterPlatform": True, } if QWebEngineSettings: webBrowserDefaults["HelpViewerType"] = 1 # eric browser @@ -2919,6 +2923,7 @@ "SpellCheckEnabled", "ShowToolbars", "MenuBarVisible", "BookmarksToolBarVisible", "StatusBarVisible", "SessionAutoSave", "LoadTabOnActivation", + "SafeBrowsingEnabled", "SafeBrowsingFilterPlatform", ]: return toBool(prefClass.settings.value( "WebBrowser/" + key, prefClass.webBrowserDefaults[key]))
--- a/WebBrowser/Download/DownloadManager.py Fri Aug 04 18:29:30 2017 +0200 +++ b/WebBrowser/Download/DownloadManager.py Fri Aug 04 18:41:50 2017 +0200 @@ -167,9 +167,29 @@ download data. @type QWebEngineDownloadItem """ - if downloadItem.url().isEmpty(): + url = downloadItem.url() + if url.isEmpty(): return + # Safe Browsing + threatLists = WebBrowserWindow.safeBrowsingManager().lookupUrl(url) + if threatLists: + threatMessages = WebBrowserWindow.safeBrowsingManager()\ + .getThreatMessages(threatLists) + res = E5MessageBox.warning( + WebBrowserWindow.getWindow(), + self.tr("Suspicuous URL detected"), + self.tr("<p>The URL <b>{0}</b> was found in the Safe Browsing" + " database.</p>{1}").format(url.toString(), + "".join(threatMessages)), + E5MessageBox.StandardButtons( + E5MessageBox.Abort | + E5MessageBox.Ignore), + E5MessageBox.Abort) + if res == E5MessageBox.Abort: + downloadItem.cancel() + return + from .DownloadItem import DownloadItem itm = DownloadItem(downloadItem, parent=self) self.__addItem(itm)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingAPIClient.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,412 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the low level interface for Google Safe Browsing. +""" + +from __future__ import unicode_literals +try: + str = unicode # __IGNORE_EXCEPTION__ +except NameError: + pass + +import json +import base64 + +from PyQt5.QtCore import pyqtSignal, QObject, QDateTime, QUrl, QByteArray, \ + QCoreApplication, QEventLoop +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply + +from WebBrowser.WebBrowserWindow import WebBrowserWindow + + +class SafeBrowsingAPIClient(QObject): + """ + Class implementing the low level interface for Google Safe Browsing. + + @signal networkError(str) emitted to indicate a network error + """ + ClientId = "eric6_API_client" + ClientVersion = "1.0.0" + + GsbUrlTemplate = "https://safebrowsing.googleapis.com/v4/{0}?key={1}" + + networkError = pyqtSignal(str) + + def __init__(self, apiKey, fairUse=True, parent=None): + """ + Constructor + + @param apiKey API key to be used + @type str + @param fairUse flag indicating to follow the fair use policy + @type bool + @param parent reference to the parent object + @type QObject + """ + self.__apiKey = apiKey + self.__fairUse = fairUse + + self.__nextRequestNoSoonerThan = QDateTime() + self.__failCount = 0 + + def setApiKey(self, apiKey): + """ + Public method to set the API key. + + @param apiKey API key to be set + @type str + """ + self.__apiKey = apiKey + + def getThreatLists(self): + """ + Public method to retrieve all available threat lists. + + @return list of threat lists + @rtype list of dict containing 'threatType', 'platformType' and + 'threatEntryType' + """ + url = QUrl(self.GsbUrlTemplate.format("threatLists", self.__apiKey)) + req = QNetworkRequest(url) + reply = WebBrowserWindow.networkManager().get(req) + + while reply.isRunning(): + QCoreApplication.processEvents(QEventLoop.AllEvents, 200) + # max. 200 ms processing + + res = None + if reply.error() != QNetworkReply.NoError: + self.networkError.emit(reply.errorString()) + else: + result = self.__extractData(reply) + res = result["threatLists"] + + reply.deleteLater() + return res + + def getThreatsUpdate(self, clientStates): + """ + Public method to fetch hash prefix updates for the given threat list. + + @param clientStates dictionary of client states with keys like + (threatType, platformType, threatEntryType) + @type dict + @return list of threat updates + @rtype list of dict + """ + requestBody = { + "client": { + "clientId": self.ClientId, + "clientVersion": self.ClientVersion, + }, + "listUpdateRequests": [], + } + + for (threatType, platformType, threatEntryType), currentState in \ + clientStates.items(): + requestBody["listUpdateRequests"].append( + { + "threatType": threatType, + "platformType": platformType, + "threatEntryType": threatEntryType, + "state": currentState, + "constraints": { + "supportedCompressions": ["RAW"], + } + } + ) + + data = QByteArray(json.dumps(requestBody).encode("utf-8")) + url = QUrl(self.GsbUrlTemplate.format("threatListUpdates:fetch", + self.__apiKey)) + req = QNetworkRequest(url) + req.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + reply = WebBrowserWindow.networkManager().post(req, data) + + while reply.isRunning(): + QCoreApplication.processEvents(QEventLoop.AllEvents, 200) + # max. 200 ms processing + + res = None + if reply.error() != QNetworkReply.NoError: + self.networkError.emit(reply.errorString()) + else: + result = self.__extractData(reply) + res = result["listUpdateResponses"] + + reply.deleteLater() + return res + + def getFullHashes(self, prefixes, clientState): + """ + Public method to find full hashes matching hash prefixes. + + @param prefixes list of hash prefixes to find + @type list of str (Python 2) or list of bytes (Python 3) + @param clientState dictionary of client states with keys like + (threatType, platformType, threatEntryType) + @type dict + @return dictionary containing the list of found hashes and the + negative cache duration + @rtype dict + """ + requestBody = { + "client": { + "clientId": self.ClientId, + "clientVersion": self.ClientVersion, + }, + "clientStates": [], + "threatInfo": { + "threatTypes": [], + "platformTypes": [], + "threatEntryTypes": [], + "threatEntries": [], + }, + } + + for prefix in prefixes: + requestBody["threatInfo"]["threatEntries"].append( + {"hash": base64.b64encode(prefix).decode("ascii")}) + + for (threatType, platformType, threatEntryType), currentState in \ + clientState.items(): + requestBody["clientStates"].append(currentState) + if threatType not in requestBody["threatInfo"]["threatTypes"]: + requestBody["threatInfo"]["threatTypes"].append(threatType) + if platformType not in \ + requestBody["threatInfo"]["platformTypes"]: + requestBody["threatInfo"]["platformTypes"].append( + platformType) + if threatEntryType not in \ + requestBody["threatInfo"]["threatEntryTypes"]: + requestBody["threatInfo"]["threatEntryTypes"].append( + threatEntryType) + + data = QByteArray(json.dumps(requestBody).encode("utf-8")) + url = QUrl(self.GsbUrlTemplate.format("fullHashes:find", + self.__apiKey)) + req = QNetworkRequest(url) + req.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + reply = WebBrowserWindow.networkManager().post(req, data) + + while reply.isRunning(): + QCoreApplication.processEvents(QEventLoop.AllEvents, 200) + # max. 200 ms processing + + res = None + if reply.error() != QNetworkReply.NoError: + self.networkError.emit(reply.errorString()) + else: + res = self.__extractData(reply) + + reply.deleteLater() + return res + + def __extractData(self, reply): + """ + Private method to extract the data of a network reply. + + @param reply reference to the network reply object + @type QNetworkReply + @return extracted data + @type list or dict + """ + result = json.loads(str(reply.readAll(), "utf-8")) + self.__setWaitDuration(result.get("minimumWaitDuration")) + return result + + def __setWaitDuration(self, minimumWaitDuration): + """ + Private method to set the minimum wait duration. + + @param minimumWaitDuration duration to be set + @type str + """ + if not self.__fairUse or minimumWaitDuration is None: + self.__nextRequestNoSoonerThan = QDateTime() + else: + waitDuration = int(float(minimumWaitDuration.rstrip("s"))) + self.__nextRequestNoSoonerThan = \ + QDateTime.currentDateTime().addSecs(waitDuration) + + def fairUseDelayExpired(self): + """ + Public method to check, if the fair use wait period has expired. + + @return flag indicating expiration + @rtype bool + """ + return ( + self.__fairUse and + QDateTime.currentDateTime() >= self.__nextRequestNoSoonerThan + ) or not self.__fairUse + + def getFairUseDelayExpirationDateTime(self): + """ + Public method to get the date and time the fair use delay will expire. + + @return fair use delay expiration date and time + @rtype QDateTime + """ + return self.__nextRequestNoSoonerThan + + @classmethod + def getThreatMessage(cls, threatType): + """ + Class method to get a warning message for the given threat type. + + @param threatType threat type to get the message for + @type str + @return threat message + @rtype str + """ + threatType = threatType.lower() + if threatType == "malware": + msg = QCoreApplication.translate( + "SafeBrowsingAPI", + "<h3>Malware Warning</h3>" + "<p>The web site you are about to visit may try to install" + " harmful programs on your computer in order to steal or" + " destroy your data.</p>") + elif threatType == "social_engineering": + msg = QCoreApplication.translate( + "SafeBrowsingAPI", + "<h3>Phishing Warning</h3>" + "<p>The web site you are about to visit may try to trick you" + " into doing something dangerous online, such as revealing" + " passwords or personal information, usually through a fake" + " website.</p>") + elif threatType == "unwanted_software": + msg = QCoreApplication.translate( + "SafeBrowsingAPI", + "<h3>Unwanted Software Warning</h3>" + "<p>The software you are about to download may negatively" + " affect your browsing or computing experience.</p>") + elif threatType == "potentially_harmful_application": + msg = QCoreApplication.translate( + "SafeBrowsingAPI", + "<h3>Potentially Harmful Application</h3>" + "<p>The web site you are about to visit may try to trick you" + " into installing applications, that may negatively affect" + " your browsing experience.</p>") + else: + # unknow threat + msg = QCoreApplication.translate( + "SafeBrowsingAPI", + "<h3>Unknown Threat Warning</h3>" + "<p>The web site you are about to visit was found in the Safe" + " Browsing Database but was not classified yet.</p>") + + return msg + + @classmethod + def getThreatType(cls, threatType): + """ + Class method to get a display string for a given threat type. + + @param threatType threat type to get display string for + @type str + @return display string + @rtype str + """ + threatType = threatType.lower() + if threatType == "malware": + displayString = QCoreApplication.translate( + "SafeBrowsingAPI", "Malware") + elif threatType == "social_engineering": + displayString = QCoreApplication.translate( + "SafeBrowsingAPI", "Phishing") + elif threatType == "unwanted_software": + displayString = QCoreApplication.translate( + "SafeBrowsingAPI", "Unwanted Software") + elif threatType == "potentially_harmful_application": + displayString = QCoreApplication.translate( + "SafeBrowsingAPI", "Harmful Application") + elif threatType == "malcious_binary": + displayString = QCoreApplication.translate( + "SafeBrowsingAPI", "Malicious Binary") + else: + displayString = QCoreApplication.translate( + "SafeBrowsingAPI", "Unknown Threat") + + return displayString + + @classmethod + def getPlatformString(cls, platformType): + """ + Class method to get the platform string for a given platform type. + + @param platformType platform type as defined in the v4 API + @type str + @return platform string + @rtype str + """ + platformStrings = { + "WINDOWS": "Windows", + "LINUX": "Linux", + "ANDROID": "Android", + "OSX": "macOS", + "IOS": "iOS", + "CHROME": "Chrome OS", + } + if platformType in platformStrings: + return platformStrings[platformType] + + if platformType == "ANY_PLATFORM": + return QCoreApplication.translate( + "SafeBrowsingAPI", "any defined platform") + elif platformType == "ALL_PLATFORMS": + return QCoreApplication.translate( + "SafeBrowsingAPI", "all defined platforms") + else: + return QCoreApplication.translate( + "SafeBrowsingAPI", "unknown platform") + + @classmethod + def getThreatEntryString(cls, threatEntry): + """ + Class method to get the threat entry string. + + @param threatEntry threat entry type as defined in the v4 API + @type str + @return threat entry string + @rtype str + """ + if threatEntry == "URL": + return "URL" + elif threatEntry == "EXECUTABLE": + return QCoreApplication.translate( + "SafeBrowsingAPI", "executable program") + else: + return QCoreApplication.translate( + "SafeBrowsingAPI", "unknown type") + + @classmethod + def getPlatformTypes(cls, platform): + """ + Class method to get the platform types for a given platform. + + @param platform platform string + @type str (one of 'linux', 'windows', 'macos') + @return list of platform types as defined in the v4 API for the + given platform + @rtype list of str + @exception ValueError raised to indicate an invalid platform string + """ + platform = platform.lower() + + platformTypes = ["ANY_PLATFORM", "ALL_PLATFORMS"] + if platform == "linux": + platformTypes.append("LINUX") + elif platform == "windows": + platformTypes.append("WINDOWS") + elif platform == "macos": + platformTypes.append("OSX") + else: + raise ValueError("Unsupported platform") + + return platformTypes
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingCache.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,770 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a cache for Google Safe Browsing. +""" + +# +# Some part of this code were ported from gglsbl.storage and adapted +# to QtSql. +# +# https://github.com/afilipovich/gglsbl +# + +from __future__ import unicode_literals, division + +import os + +from PyQt5.QtCore import QObject, QByteArray, QCryptographicHash, \ + QCoreApplication, QEventLoop +from PyQt5.QtSql import QSql, QSqlDatabase, QSqlQuery + +from .SafeBrowsingUtilities import toHex + + +class ThreatList(object): + """ + Class implementing the threat list info. + """ + def __init__(self, threatType, platformType, threatEntryType): + """ + Constructor + + @param threatType threat type + @type str + @param platformType platform type + @type str + @param threatEntryType threat entry type + @type str + """ + self.threatType = threatType + self.platformType = platformType + self.threatEntryType = threatEntryType + + @classmethod + def fromApiEntry(cls, entry): + """ + Class method to instantiate a threat list given a threat list entry + dictionary. + + @param entry threat list entry dictionary + @type dict + @return instantiated object + @rtype ThreatList + """ + return cls(entry['threatType'], entry['platformType'], + entry['threatEntryType']) + + def asTuple(self): + """ + Public method to convert the object to a tuple. + + @return tuple containing the threat list info + @rtype tuple of (str, str, str) + """ + return (self.threatType, self.platformType, self.threatEntryType) + + def __repr__(self): + """ + Special method to generate a printable representation. + + @return printable representation + @rtype str + """ + return '/'.join(self.asTuple()) + + +class HashPrefixList(object): + """ + Class implementing a container for threat list data. + """ + def __init__(self, prefixLength, rawHashes): + """ + Constructor + + @param prefixLength length of each hash prefix + @type int + @param rawHashes raw hash prefixes of given length concatenated and + sorted in lexicographical order + @type str + """ + self.__prefixLength = prefixLength + self.__rawHashes = rawHashes + + def __len__(self): + """ + Special method to calculate the number of entries. + + @return length + @rtype int + """ + return len(self.__rawHashes) // self.__prefixLength + + def __iter__(self): + """ + Special method to iterate over the raw hashes. + + @return iterator object + @rtype iterator + """ + n = self.__prefixLength + return (self.__rawHashes[index:index + n] + for index in range(0, len(self.__rawHashes), n) + ) + + +class SafeBrowsingCache(QObject): + """ + Class implementing a cache for Google Safe Browsing. + """ + create_threat_list_stmt = """ + CREATE TABLE threat_list + (threat_type character varying(128) NOT NULL, + platform_type character varying(128) NOT NULL, + threat_entry_type character varying(128) NOT NULL, + client_state character varying(42), + timestamp timestamp without time zone DEFAULT current_timestamp, + PRIMARY KEY (threat_type, platform_type, threat_entry_type) + ) + """ + drop_threat_list_stmt = """DROP TABLE IF EXISTS threat_list""" + + create_full_hashes_stmt = """ + CREATE TABLE full_hash + (value BLOB NOT NULL, + threat_type character varying(128) NOT NULL, + platform_type character varying(128) NOT NULL, + threat_entry_type character varying(128) NOT NULL, + downloaded_at timestamp without time zone DEFAULT current_timestamp, + expires_at timestamp without time zone + NOT NULL DEFAULT current_timestamp, + malware_threat_type varchar(32), + PRIMARY KEY (value, threat_type, platform_type, threat_entry_type) + ) + """ + drop_full_hashes_stmt = """DROP TABLE IF EXISTS full_hash""" + + create_hash_prefix_stmt = """ + CREATE TABLE hash_prefix + (value BLOB NOT NULL, + cue character varying(4) NOT NULL, + threat_type character varying(128) NOT NULL, + platform_type character varying(128) NOT NULL, + threat_entry_type character varying(128) NOT NULL, + timestamp timestamp without time zone DEFAULT current_timestamp, + negative_expires_at timestamp without time zone + NOT NULL DEFAULT current_timestamp, + PRIMARY KEY (value, threat_type, platform_type, threat_entry_type), + FOREIGN KEY(threat_type, platform_type, threat_entry_type) + REFERENCES threat_list(threat_type, platform_type, threat_entry_type) + ON DELETE CASCADE + ) + """ + drop_hash_prefix_stmt = """DROP TABLE IF EXISTS hash_prefix""" + + create_full_hash_cue_idx = """ + CREATE INDEX idx_hash_prefix_cue ON hash_prefix (cue) + """ + drop_full_hash_cue_idx = """DROP INDEX IF EXISTS idx_hash_prefix_cue""" + + create_full_hash_expires_idx = """ + CREATE INDEX idx_full_hash_expires_at ON full_hash (expires_at) + """ + drop_full_hash_expires_idx = """ + DROP INDEX IF EXISTS idx_full_hash_expires_at + """ + + create_full_hash_value_idx = """ + CREATE INDEX idx_full_hash_value ON full_hash (value) + """ + drop_full_hash_value_idx = """DROP INDEX IF EXISTS idx_full_hash_value""" + + def __init__(self, dbPath, parent=None): + """ + Constructor + + @param dbPath path to store the cache DB into + @type str + @param parent reference to the parent object + @type QObject + """ + super(SafeBrowsingCache, self).__init__(parent) + + self.__connectionName = "SafeBrowsingCache" + + if not os.path.exists(dbPath): + os.makedirs(dbPath) + + self.__dbFileName = os.path.join(dbPath, "SafeBrowsingCache.db") + preparationNeeded = not os.path.exists(self.__dbFileName) + + self.__openCacheDb() + if preparationNeeded: + self.prepareCacheDb() + + def close(self): + """ + Public method to close the database. + """ + if QSqlDatabase.database(self.__connectionName).isOpen(): + QSqlDatabase.database(self.__connectionName).close() + QSqlDatabase.removeDatabase(self.__language) + + def __openCacheDb(self): + """ + Private method to open the cache database. + + @return flag indicating the open state + @rtype bool + """ + db = QSqlDatabase.database(self.__connectionName, False) + if not db.isValid(): + # the database connection is a new one + db = QSqlDatabase.addDatabase("QSQLITE", self.__connectionName) + db.setDatabaseName(self.__dbFileName) + opened = db.open() + if not opened: + QSqlDatabase.removeDatabase(self.__connectionName) + else: + opened = True + return opened + + def prepareCacheDb(self): + """ + Public method to prepare the cache database. + """ + db = QSqlDatabase.database(self.__connectionName) + db.transaction() + try: + query = QSqlQuery(db) + # step 1: drop old tables + query.exec_(self.drop_threat_list_stmt) + query.exec_(self.drop_full_hashes_stmt) + query.exec_(self.drop_hash_prefix_stmt) + # step 2: drop old indices + query.exec_(self.drop_full_hash_cue_idx) + query.exec_(self.drop_full_hash_expires_idx) + query.exec_(self.drop_full_hash_value_idx) + # step 3: create tables + query.exec_(self.create_threat_list_stmt) + query.exec_(self.create_full_hashes_stmt) + query.exec_(self.create_hash_prefix_stmt) + # step 4: create indices + query.exec_(self.create_full_hash_cue_idx) + query.exec_(self.create_full_hash_expires_idx) + query.exec_(self.create_full_hash_value_idx) + finally: + del query + db.commit() + + def lookupFullHashes(self, hashValues): + """ + Public method to get a list of threat lists and expiration flag + for the given hashes if a hash is blacklisted. + + @param hashValues list of hash values to look up + @type list of bytes + @return list of tuples containing the threat list info and the + expiration flag + @rtype list of tuple of (ThreatList, bool) + """ + queryStr = """ + SELECT threat_type, platform_type, threat_entry_type, + expires_at < current_timestamp AS has_expired + FROM full_hash WHERE value IN ({0}) + """ + output = [] + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare( + queryStr.format(",".join(["?"] * len(hashValues)))) + for hashValue in hashValues: + query.addBindValue(QByteArray(hashValue), + QSql.In | QSql.Binary) + + query.exec_() + + while query.next(): + threatType = query.value(0) + platformType = query.value(1) + threatEntryType = query.value(2) + hasExpired = bool(query.value(3)) + threatList = ThreatList(threatType, platformType, + threatEntryType) + output.append((threatList, hasExpired)) + del query + finally: + db.commit() + + return output + + def lookupHashPrefix(self, prefixes): + """ + Public method to look up hash prefixes in the local cache. + + @param prefixes list of hash prefixes to look up + @type list of bytes + @return list of tuples containing the threat list, full hash and + negative cache expiration flag + @rtype list of tuple of (ThreatList, bytes, bool) + """ + queryStr = """ + SELECT value,threat_type,platform_type,threat_entry_type, + negative_expires_at < current_timestamp AS negative_cache_expired + FROM hash_prefix WHERE cue IN ({0}) + """ + output = [] + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare( + queryStr.format(",".join(["?"] * len(prefixes)))) + for prefix in prefixes: + query.addBindValue(prefix) + + query.exec_() + + while query.next(): + fullHash = bytes(query.value(0)) + threatType = query.value(1) + platformType = query.value(2) + threatEntryType = query.value(3) + negativeCacheExpired = bool(query.value(4)) + threatList = ThreatList(threatType, platformType, + threatEntryType) + output.append((threatList, fullHash, negativeCacheExpired)) + del query + finally: + db.commit() + + return output + + def storeFullHash(self, threatList, hashValue, cacheDuration, + malwareThreatType): + """ + Public method to store full hash data in the cache database. + + @param threatList threat list info object + @type ThreatList + @param hashValue hash to be stored + @type bytes + @param cacheDuration duration the data should remain in the cache + @type int or float + @param malwareThreatType threat type of the malware + @type str + """ + insertQueryStr = """ + INSERT OR IGNORE INTO full_hash + (value, threat_type, platform_type, threat_entry_type, + malware_threat_type, downloaded_at) + VALUES + (?, ?, ?, ?, ?, current_timestamp) + """ + updateQueryStr = """ + UPDATE full_hash SET + expires_at=datetime(current_timestamp, '+{0} SECONDS') + WHERE value=? AND threat_type=? AND platform_type=? AND + threat_entry_type=? + """.format(int(cacheDuration)) + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(insertQueryStr) + query.addBindValue(QByteArray(hashValue), + QSql.In | QSql.Binary) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + query.addBindValue(malwareThreatType) + query.exec_() + del query + + query = QSqlQuery(db) + query.prepare(updateQueryStr) + query.addBindValue(QByteArray(hashValue), + QSql.In | QSql.Binary) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + query.exec_() + del query + finally: + db.commit() + + def deleteHashPrefixList(self, threatList): + """ + Public method to delete hash prefixes for a given threat list. + + @param threatList threat list info object + @type ThreatList + """ + queryStr = """ + DELETE FROM hash_prefix + WHERE threat_type=? AND platform_type=? AND threat_entry_type=? + """ + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(queryStr) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + query.exec_() + del query + finally: + db.commit() + + def cleanupFullHashes(self, keepExpiredFor=43200): + """ + Public method to clean up full hash entries expired more than the + given time. + + @param keepExpiredFor time period in seconds of entries to be expired + @type int or float + """ + queryStr = """ + DELETE FROM full_hash + WHERE expires_at=datetime(current_timestamp, '{0} SECONDS') + """.format(int(keepExpiredFor)) + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(queryStr) + query.exec_() + del query + finally: + db.commit() + + def updateHashPrefixExpiration(self, threatList, hashPrefix, + negativeCacheDuration): + """ + Public method to update the hash prefix expiration time. + + @param threatList threat list info object + @type ThreatList + @param hashPrefix hash prefix + @type bytes + @param negativeCacheDuration time in seconds the entry should remain + in the cache + @type int or float + """ + queryStr = """ + UPDATE hash_prefix + SET negative_expires_at=datetime(current_timestamp, '+{0} SECONDS') + WHERE value=? AND threat_type=? AND platform_type=? AND + threat_entry_type=? + """.format(int(negativeCacheDuration)) + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(queryStr) + query.addBindValue(QByteArray(hashPrefix), + QSql.In | QSql.Binary) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + query.exec_() + del query + finally: + db.commit() + + def getThreatLists(self): + """ + Public method to get the available threat lists. + + @return list of available threat lists + @rtype list of tuples of (ThreatList, str) + """ + queryStr = """ + SELECT threat_type,platform_type,threat_entry_type,client_state + FROM threat_list + """ + output = [] + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(queryStr) + + query.exec_() + + while query.next(): + threatType = query.value(0) + platformType = query.value(1) + threatEntryType = query.value(2) + clientState = query.value(3) + threatList = ThreatList(threatType, platformType, + threatEntryType) + output.append((threatList, clientState)) + del query + finally: + db.commit() + + return output + + def addThreatList(self, threatList): + """ + Public method to add a threat list to the cache. + + @param threatList threat list to be added + @type ThreatList + """ + queryStr = """ + INSERT OR IGNORE INTO threat_list + (threat_type, platform_type, threat_entry_type, timestamp) + VALUES (?, ?, ?, current_timestamp) + """ + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(queryStr) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + query.exec_() + del query + finally: + db.commit() + + def deleteThreatList(self, threatList): + """ + Public method to delete a threat list from the cache. + + @param threatList threat list to be deleted + @type ThreatList + """ + queryStr = """ + DELETE FROM threat_list + WHERE threat_type=? AND platform_type=? AND threat_entry_type=? + """ + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(queryStr) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + query.exec_() + del query + finally: + db.commit() + + def updateThreatListClientState(self, threatList, clientState): + """ + Public method to update the client state of a threat list. + + @param threatList threat list to update the client state for + @type ThreatList + @param clientState new client state + @type str + """ + queryStr = """ + UPDATE threat_list SET timestamp=current_timestamp, client_state=? + WHERE threat_type=? AND platform_type=? AND threat_entry_type=? + """ + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(queryStr) + query.addBindValue(clientState) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + query.exec_() + del query + finally: + db.commit() + + def hashPrefixListChecksum(self, threatList): + """ + Public method to calculate the SHA256 checksum for an alphabetically + sorted concatenated list of hash prefixes. + + @param threatList threat list to calculate checksum for + @type ThreatList + @return SHA256 checksum + @rtype bytes + """ + queryStr = """ + SELECT value FROM hash_prefix + WHERE threat_type=? AND platform_type=? AND threat_entry_type=? + ORDER BY value + """ + checksum = None + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + sha256Hash = QCryptographicHash(QCryptographicHash.Sha256) + try: + query = QSqlQuery(db) + query.prepare(queryStr) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + + query.exec_() + + while query.next(): + sha256Hash.addData(query.value(0)) + QCoreApplication.processEvents(QEventLoop.AllEvents, 200) + del query + finally: + db.commit() + + checksum = bytes(sha256Hash.result()) + + return checksum + + def populateHashPrefixList(self, threatList, prefixes): + """ + Public method to populate the hash prefixes for a threat list. + + @param threatList threat list of the hash prefixes + @type ThreatList + @param prefixes list of hash prefixes to be inserted + @type HashPrefixList + """ + queryStr = """ + INSERT INTO hash_prefix + (value, cue, threat_type, platform_type, threat_entry_type, + timestamp) + VALUES (?, ?, ?, ?, ?, current_timestamp) + """ + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + for prefix in prefixes: + query = QSqlQuery(db) + query.prepare(queryStr) + query.addBindValue(QByteArray(prefix), + QSql.In | QSql.Binary) + query.addBindValue(toHex(prefix[:4])) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + query.exec_() + del query + finally: + db.commit() + + def getHashPrefixValuesToRemove(self, threatList, indexes): + """ + Public method to get the hash prefix values to be removed from the + cache. + + @param threatList threat list to remove prefixes from + @type ThreatList + @param indexes list of indexes of prefixes to be removed + @type list of int + @return list of hash prefixes to be removed + @rtype list of bytes + """ + queryStr = """ + SELECT value FROM hash_prefix + WHERE threat_type=? AND platform_type=? AND threat_entry_type=? + ORDER BY value + """ + indexes = set(indexes) + output = [] + + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + query = QSqlQuery(db) + query.prepare(queryStr) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + + query.exec_() + + index = 0 + while query.next(): + if index in indexes: + prefix = bytes(query.value(0)) + output.append(prefix) + index += 1 + del query + finally: + db.commit() + + return output + + def removeHashPrefixIndices(self, threatList, indexes): + """ + Public method to remove hash prefixes from the cache. + + @param threatList threat list to delete hash prefixes of + @type ThreatList + @param indexes list of indexes of prefixes to be removed + @type list of int + """ + queryStr = """ + DELETE FROM hash_prefix + WHERE threat_type=? AND platform_type=? AND + threat_entry_type=? AND value IN ({0}) + """ + batchSize = 40 + + prefixesToRemove = self.getHashPrefixValuesToRemove( + threatList, indexes) + if prefixesToRemove: + db = QSqlDatabase.database(self.__connectionName) + if db.isOpen(): + db.transaction() + try: + for index in range(0, len(prefixesToRemove), batchSize): + removeBatch = \ + prefixesToRemove[index:(index + batchSize)] + + query = QSqlQuery(db) + query.prepare( + queryStr.format(",".join(["?"] * len(removeBatch))) + ) + query.addBindValue(threatList.threatType) + query.addBindValue(threatList.platformType) + query.addBindValue(threatList.threatEntryType) + for prefix in removeBatch: + query.addBindValue(QByteArray(prefix), + QSql.In | QSql.Binary) + query.exec_() + del query + finally: + db.commit()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingDialog.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to configure safe browsing support. +""" + +from __future__ import unicode_literals + +from PyQt5.QtCore import pyqtSlot, Qt, QUrl +from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QAbstractButton, \ + QApplication + +from E5Gui import E5MessageBox + +from .Ui_SafeBrowsingDialog import Ui_SafeBrowsingDialog + +import UI.PixmapCache +import Preferences + + +class SafeBrowsingDialog(QDialog, Ui_SafeBrowsingDialog): + """ + Class implementing a dialog to configure safe browsing support. + """ + def __init__(self, manager, parent=None): + """ + Constructor + + @param manager reference to the safe browsing manager + @type SafeBrowsingManager + @param parent reference to the parent widget + @type QWidget + """ + super(SafeBrowsingDialog, self).__init__(parent) + self.setupUi(self) + self.setWindowFlags(Qt.Window) + + self.__manager = manager + self.__manager.progressMessage.connect(self.__setProgressMessage) + self.__manager.progress.connect(self.__setProgress) + + self.iconLabel.setPixmap( + UI.PixmapCache.getPixmap("safeBrowsing48.png")) + + self.__gsbHelpDialog = None + + self.__enabled = Preferences.getWebBrowser("SafeBrowsingEnabled") + self.__apiKey = Preferences.getWebBrowser("SafeBrowsingApiKey") + self.__filterPlatform = Preferences.getWebBrowser( + "SafeBrowsingFilterPlatform") + + self.buttonBox.setFocus() + + msh = self.minimumSizeHint() + self.resize(max(self.width(), msh.width()), msh.height()) + + def show(self): + """ + Public slot to show the dialog. + """ + self.gsbGroupBox.setChecked(self.__enabled) + self.gsbApiKeyEdit.setText(self.__apiKey) + self.gsbFilterPlatformCheckBox.setChecked(self.__filterPlatform) + + self.__updateCacheButtons() + + super(SafeBrowsingDialog, self).show() + + @pyqtSlot() + def on_gsbHelpButton_clicked(self): + """ + Private slot to show some help text "How to create a safe + browsing API key.". + """ + if self.__gsbHelpDialog is None: + from E5Gui.E5SimpleHelpDialog import E5SimpleHelpDialog + from . import SafeBrowsingHelp + + helpStr = SafeBrowsingHelp() + self.__gsbHelpDialog = E5SimpleHelpDialog( + title=self.tr("Google Safe Browsing API Help"), + helpStr=helpStr, parent=self) + + self.__gsbHelpDialog.show() + + @pyqtSlot(QAbstractButton) + def on_buttonBox_clicked(self, button): + """ + Private slot called by a button of the button box clicked. + + @param button button that was clicked (QAbstractButton) + """ + if button == self.buttonBox.button(QDialogButtonBox.Close): + self.close() + + @pyqtSlot() + def __save(self): + """ + Private slot to save the configuration. + + @return flag indicating success + @rtype bool + """ + self.__enabled = self.gsbGroupBox.isChecked() + self.__apiKey = self.gsbApiKeyEdit.text() + self.__filterPlatform = self.gsbFilterPlatformCheckBox.isChecked() + + Preferences.setWebBrowser("SafeBrowsingEnabled", self.__enabled) + Preferences.setWebBrowser("SafeBrowsingApiKey", self.__apiKey) + Preferences.setWebBrowser("SafeBrowsingFilterPlatform", + self.__filterPlatform) + + self.__manager.configurationChanged() + + self.__updateCacheButtons() + + return True + + def closeEvent(self, evt): + """ + Protected method to handle close events. + + @param evt reference to the close event + @type QCloseEvent + """ + if self.__okToClose(): + evt.accept() + else: + evt.ignore() + + def __isModified(self): + """ + Private method to check, if the dialog contains modified data. + + @return flag indicating the presence of modified data + @rtype bool + """ + return ( + self.__enabled != self.gsbGroupBox.isChecked() or + self.__apiKey != self.gsbApiKeyEdit.text() or + self.__filterPlatform != self.gsbFilterPlatformCheckBox.isChecked() + ) + + def __okToClose(self): + """ + Private method to check, if it is safe to close the dialog. + + @return flag indicating safe to close + @rtype bool + """ + QApplication.restoreOverrideCursor() + if self.__isModified(): + res = E5MessageBox.okToClearData( + self, + self.tr("Safe Browsing Management"), + self.tr("""The dialog contains unsaved changes."""), + self.__save) + if not res: + return False + return True + + def __updateCacheButtons(self): + """ + Private method to set enabled state of the cache buttons. + """ + enable = self.__enabled and bool(self.__apiKey) + + self.updateCacheButton.setEnabled(enable) + self.clearCacheButton.setEnabled(enable) + + @pyqtSlot() + def on_updateCacheButton_clicked(self): + """ + Private slot to update the local cache database. + """ + E5MessageBox.information( + self, + self.tr("Update Safe Browsing Cache"), + self.tr("""Updating the Safe Browsing cache might be a lengthy""" + """ operation. Please be patient!""")) + + QApplication.setOverrideCursor(Qt.WaitCursor) + ok, error = self.__manager.updateHashPrefixCache() + self.__resetProgress() + QApplication.restoreOverrideCursor() + if not ok: + if error: + E5MessageBox.critical( + self, + self.tr("Update Safe Browsing Cache"), + self.tr("""<p>Updating the Safe Browsing cache failed.""" + """</p><p>Reason: {0}</p>""").format(error)) + else: + E5MessageBox.critical( + self, + self.tr("Update Safe Browsing Cache"), + self.tr("""<p>Updating the Safe Browsing cache failed.""" + """</p>""")) + + @pyqtSlot() + def on_clearCacheButton_clicked(self): + """ + Private slot to clear the local cache database. + """ + res = E5MessageBox.yesNo( + self, + self.tr("Clear Safe Browsing Cache"), + self.tr("""Do you really want to clear the Safe Browsing cache?""" + """ Re-populating it might take some time.""")) + if res: + QApplication.setOverrideCursor(Qt.WaitCursor) + self.__manager.fullCacheCleanup() + QApplication.restoreOverrideCursor() + + @pyqtSlot(str, int) + def __setProgressMessage(self, message, maximum): + """ + Private slot to set the progress message and the maximum value. + + @param message progress message to be set + @type str + @param maximum maximum value to be set + @type int + """ + self.progressLabel.setText(message) + self.progressBar.setMaximum(maximum) + self.progressBar.setValue(0) + + @pyqtSlot(int) + def __setProgress(self, value): + """ + Private slot to set the progress value. + + @param value progress value to be set + @type int + """ + self.progressBar.setValue(value) + + def __resetProgress(self): + """ + Private method to reset the progress info. + """ + self.progressLabel.clear() + self.progressBar.setMaximum(100) + self.progressBar.setValue(0) + + @pyqtSlot(str) + def on_urlEdit_textChanged(self, text): + """ + Private slot to handle changes of the entered URL text. + + @param text entered URL text + @type str + """ + url = QUrl.fromUserInput(text) + enable = ( + url.isValid() and + bool(url.scheme()) and + url.scheme() not in self.__manager.getIgnoreSchemes() + ) + self.urlCheckButton.setEnabled(enable) + + @pyqtSlot() + def on_urlCheckButton_clicked(self): + """ + Private slot to check the entered URL. + """ + # Malicious URL for testing: + # http://malware.testing.google.test/testing/malware/* + # http://ianfette.org + # + urlStr = self.urlEdit.text() + url = QUrl.fromUserInput(urlStr) + threatLists = self.__manager.lookupUrl(url) + + if threatLists: + threatMessages = self.__manager.getThreatMessages(threatLists) + E5MessageBox.warning( + self, + self.tr("Check URL"), + self.tr("<p>The URL <b>{0}</b> was found in the Safe Browsing" + " Database.</p>{1}").format(urlStr, + "".join(threatMessages)) + ) + else: + E5MessageBox.information( + self, + self.tr("Check URL"), + self.tr("<p>The URL <b>{0}</b> was not found in the Safe" + " Browsing Database and may be considered safe.</p>") + .format(urlStr) + ) + + @pyqtSlot() + def on_saveButton_clicked(self): + """ + Private slot to save the configuration data. + """ + self.__save()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingDialog.ui Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,239 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>SafeBrowsingDialog</class> + <widget class="QDialog" name="SafeBrowsingDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>650</width> + <height>461</height> + </rect> + </property> + <property name="windowTitle"> + <string>Safe Browsing Management</string> + </property> + <property name="sizeGripEnabled"> + <bool>true</bool> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QGroupBox" name="gsbGroupBox"> + <property name="toolTip"> + <string>Select to enable the Google sage browsing support</string> + </property> + <property name="title"> + <string>Enable Google Safe Browsing</string> + </property> + <property name="checkable"> + <bool>true</bool> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0" rowspan="2"> + <widget class="QLabel" name="iconLabel"> + <property name="minimumSize"> + <size> + <width>48</width> + <height>48</height> + </size> + </property> + <property name="text"> + <string notr="true">Icon</string> + </property> + </widget> + </item> + <item row="0" column="1" colspan="2"> + <widget class="QCheckBox" name="gsbFilterPlatformCheckBox"> + <property name="toolTip"> + <string>Select to check against the current platform only</string> + </property> + <property name="text"> + <string>Adjust to current platform</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QLabel" name="label_14"> + <property name="text"> + <string>API Key:</string> + </property> + </widget> + </item> + <item row="1" column="2"> + <widget class="QLineEdit" name="gsbApiKeyEdit"> + <property name="toolTip"> + <string>Enter the Google Safe Browsing API key</string> + </property> + </widget> + </item> + <item row="2" column="1" colspan="2"> + <widget class="QPushButton" name="gsbHelpButton"> + <property name="toolTip"> + <string>Press to get some help about obtaining the API key</string> + </property> + <property name="text"> + <string>Google Safe Browsing API Help</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="saveButton"> + <property name="toolTip"> + <string>Press to save the current configuration settings</string> + </property> + <property name="text"> + <string>Save Configuration</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <widget class="QGroupBox" name="groupBox"> + <property name="title"> + <string>Manage Local Cache</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QPushButton" name="updateCacheButton"> + <property name="toolTip"> + <string>Press to update the local cache database</string> + </property> + <property name="text"> + <string>Update Cache</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="clearCacheButton"> + <property name="toolTip"> + <string>Press to clear the local cache database</string> + </property> + <property name="text"> + <string>Clear Cache</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QLabel" name="progressLabel"> + <property name="text"> + <string notr="true"/> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QProgressBar" name="progressBar"> + <property name="value"> + <number>0</number> + </property> + <property name="format"> + <string>%v/%m</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox_2"> + <property name="title"> + <string>URL Check</string> + </property> + <layout class="QGridLayout" name="gridLayout_2"> + <item row="0" column="0"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>URL:</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="E5ClearableLineEdit" name="urlEdit"/> + </item> + <item row="1" column="0" colspan="2"> + <widget class="QPushButton" name="urlCheckButton"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="toolTip"> + <string>Press to check the entered URL</string> + </property> + <property name="text"> + <string>Check URL</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Close</set> + </property> + </widget> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>E5ClearableLineEdit</class> + <extends>QLineEdit</extends> + <header>E5Gui/E5LineEdit.h</header> + </customwidget> + </customwidgets> + <tabstops> + <tabstop>gsbGroupBox</tabstop> + <tabstop>gsbFilterPlatformCheckBox</tabstop> + <tabstop>gsbApiKeyEdit</tabstop> + <tabstop>gsbHelpButton</tabstop> + <tabstop>saveButton</tabstop> + <tabstop>updateCacheButton</tabstop> + <tabstop>clearCacheButton</tabstop> + <tabstop>urlEdit</tabstop> + <tabstop>urlCheckButton</tabstop> + </tabstops> + <resources/> + <connections/> +</ui>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingInfoWidget.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a widget to show some threat information. +""" + +from __future__ import unicode_literals + +from PyQt5.QtCore import Qt, QPoint +from PyQt5.QtWidgets import QMenu, QLabel, QHBoxLayout, QSizePolicy + +import UI.PixmapCache + + +class SafeBrowsingInfoWidget(QMenu): + """ + Class implementing a widget to show some threat information. + """ + def __init__(self, info, parent=None): + """ + Constructor + + @param info information string to be shown + @type str + @param parent reference to the parent widget + @type QWidget + """ + super(SafeBrowsingInfoWidget, self).__init__(parent) + + self.setMinimumWidth(500) + + layout = QHBoxLayout(self) + + iconLabel = QLabel(self) + iconLabel.setPixmap(UI.PixmapCache.getPixmap("safeBrowsing48.png")) + layout.addWidget(iconLabel, 0, Qt.AlignTop) + + infoLabel = QLabel(self) + infoLabel.setWordWrap(True) + infoLabel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + infoLabel.setText(info) + layout.addWidget(infoLabel, 0, Qt.AlignTop) + + def showAt(self, pos): + """ + Public method to show the widget. + + @param pos position to show at + @type QPoint + """ + self.adjustSize() + xpos = pos.x() - self.width() + if xpos < 0: + xpos = 10 + p = QPoint(xpos, pos.y() + 10) + self.move(p) + self.show()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingLabel.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the label to show some SSL info. +""" + +from __future__ import unicode_literals + +from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QPoint +from PyQt5.QtWidgets import QLabel + + +class SafeBrowsingLabel(QLabel): + """ + Class implementing a label to show some Safe Browsing info. + + @signal clicked(pos) emitted to indicate a click of the label (QPoint) + """ + clicked = pyqtSignal(QPoint) + + nokStyle = "QLabel { color : white; background-color : red; }" + + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent widget (QWidget) + """ + super(SafeBrowsingLabel, self).__init__(parent) + + self.setFocusPolicy(Qt.NoFocus) + self.setCursor(Qt.PointingHandCursor) + + self.setStyleSheet(SafeBrowsingLabel.nokStyle) + + self.__threatType = "" + self.__threatMessages = "" + + self.__deafultText = self.tr("Malicious Site") + self.__updateLabel() + + def mouseReleaseEvent(self, evt): + """ + Protected method to handle mouse release events. + + @param evt reference to the mouse event (QMouseEvent) + """ + if evt.button() == Qt.LeftButton: + self.clicked.emit(evt.globalPos()) + else: + super(SafeBrowsingLabel, self).mouseReleaseEvent(evt) + + def mouseDoubleClickEvent(self, evt): + """ + Protected method to handle mouse double click events. + + @param evt reference to the mouse event (QMouseEvent) + """ + if evt.button() == Qt.LeftButton: + self.clicked.emit(evt.globalPos()) + else: + super(SafeBrowsingLabel, self).mouseDoubleClickEvent(evt) + + @pyqtSlot() + def __updateLabel(self): + """ + Private slot to update the label text. + """ + if self.__threatType: + self.setText(self.__threatType) + else: + self.setText(self.__deafultText) + + @pyqtSlot(str, str) + def setThreatInfo(self, threatType, threatMessages): + """ + Public slot to set threat information. + + @param threatType threat type + @type str + @param threatMessages more verbose info about detected threats + @type str + """ + self.__threatType = threatType + self.__threatMessages = threatMessages + + self.__updateLabel() + + def getThreatInfo(self): + """ + Public method to get the threat info text. + + @return threat info text + @rtype str + """ + return self.__threatMessages
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingManager.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,485 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the interface for Google Safe Browsing. +""" + +# +# Some part of this code were ported from gglsbl.client and adapted +# to Qt. +# +# https://github.com/afilipovich/gglsbl +# + +from __future__ import unicode_literals + +import os +import base64 + +from PyQt5.QtCore import pyqtSignal, QObject, QCoreApplication, QUrl + +import Preferences +import Utilities + +from .SafeBrowsingAPIClient import SafeBrowsingAPIClient +from .SafeBrowsingCache import SafeBrowsingCache, ThreatList, HashPrefixList +from .SafeBrowsingUrl import SafeBrowsingUrl +from .SafeBrowsingUtilities import toHex + + +class SafeBrowsingManager(QObject): + """ + Class implementing the interface for Google Safe Browsing. + + @signal progressMessage(message,maximum) emitted to give a message for the + action about to be performed and the maximum value + @signal progress(current) emitted to signal the current progress + """ + progressMessage = pyqtSignal(str, int) + progress = pyqtSignal(int) + + def __init__(self): + """ + Constructor + """ + super(SafeBrowsingManager, self).__init__() + + self.__apiKey = Preferences.getWebBrowser("SafeBrowsingApiKey") + if self.__apiKey: + self.__apiClient = SafeBrowsingAPIClient(self.__apiKey, + parent=self) + # TODO: switch these after debugging is finished +## self.__apiClient = SafeBrowsingAPIClient(self.__apiKey, +## parent=self, +## fairUse=False) + else: + self.__apiClient = None + + self.__enabled = ( + Preferences.getWebBrowser("SafeBrowsingEnabled") and + bool(self.__apiKey)) + + gsbCachePath = os.path.join( + Utilities.getConfigDir(), "web_browser", "safe_browsing") + self.__cache = SafeBrowsingCache(gsbCachePath, self) + + self.__gsbDialog = None + self.__setPlatforms() + + def configurationChanged(self): + """ + Public method to handle changes of the settings. + """ + apiKey = Preferences.getWebBrowser("SafeBrowsingApiKey") + if apiKey != self.__apiKey: + self.__apiKey = apiKey + if self.__apiKey: + if self.__apiClient: + self.__apiClient.setApiKey(self.__apiKey) + else: + self.__apiClient = SafeBrowsingAPIClient(self.__apiKey, + parent=self) + # TODO: switch these after debugging is finished +## self.__apiClient = SafeBrowsingAPIClient(self.__apiKey, +## parent=self, +## fairUse=False) + + self.__enabled = ( + Preferences.getWebBrowser("SafeBrowsingEnabled") and + bool(self.__apiKey)) + + self.__setPlatforms() + + def __setPlatforms(self): + """ + Private method to set the platforms to be checked against. + """ + self.__platforms = None + if Preferences.getWebBrowser("SafeBrowsingFilterPlatform"): + if Utilities.isWindowsPlatform(): + platform = "windows" + elif Utilities.isMacPlatform(): + platform = "macos" + else: + # treat all other platforms like linux + platform = "linux" + self.__platforms = SafeBrowsingAPIClient.getPlatformTypes(platform) + + def isEnabled(self): + """ + Public method to check, if safe browsing is enabled. + + @return flag indicating the enabled state + @rtype bool + """ + return self.__enabled + + def close(self): + """ + Public method to close the safe browsing interface. + """ + self.__cache.close() + + def fairUseDelayExpired(self): + """ + Public method to check, if the fair use wait period has expired. + + @return flag indicating expiration + @rtype bool + """ + return self.__enabled and self.__apiClient.fairUseDelayExpired() + + def updateHashPrefixCache(self): + """ + Public method to load or update the locally cached threat lists. + + @return flag indicating success and an error message + @rtype tuple of (bool, str) + """ + if not self.__enabled: + return False, self.tr("Safe Browsing is disabled.") + + if not self.__apiClient.fairUseDelayExpired(): + return False, \ + self.tr("The fair use wait period has not expired yet." + "Expiration will be at {0}.").format( + self.__apiClient.getFairUseDelayExpirationDateTime() + .toString("yyyy-MM-dd, HH:mm:ss")) + + # step 1: remove expired hashes + self.__cache.cleanupFullHashes() + + # step 2: update threat lists + threatListsForRemove = {} + for threatList, clientState in self.__cache.getThreatLists(): + threatListsForRemove[repr(threatList)] = threatList + threatLists = self.__apiClient.getThreatLists() + maximum = len(threatLists) + current = 0 + self.progressMessage.emit(self.tr("Updating threat lists"), maximum) + for entry in threatLists: + current += 1 + self.progress.emit(current) + QCoreApplication.processEvents() + threatList = ThreatList.fromApiEntry(entry) + if self.__platforms is None or \ + threatList.platformType in self.__platforms: + self.__cache.addThreatList(threatList) + key = repr(threatList) + if key in threatListsForRemove: + del threatListsForRemove[key] + maximum = len(threatListsForRemove.values()) + current = 0 + self.progressMessage.emit(self.tr("Deleting obsolete threat lists"), + maximum) + for threatList in threatListsForRemove.values(): + current += 1 + self.progress.emit(current) + QCoreApplication.processEvents() + self.__cache.deleteHashPrefixList(threatList) + self.__cache.deleteThreatList(threatList) + del threatListsForRemove + + # step 3: update threats + threatLists = self.__cache.getThreatLists() + clientStates = {} + for threatList, clientState in threatLists: + clientStates[threatList.asTuple()] = clientState + threatsUpdateResponses = \ + self.__apiClient.getThreatsUpdate(clientStates) + maximum = len(threatsUpdateResponses) + current = 0 + self.progressMessage.emit(self.tr("Updating hash prefixes"), maximum) + for response in threatsUpdateResponses: + current += 1 + self.progress.emit(current) + QCoreApplication.processEvents() + responseThreatList = ThreatList.fromApiEntry(response) + if response["responseType"] == "FULL_UPDATE": + self.__cache.deleteHashPrefixList(responseThreatList) + for removal in response.get("removals", []): + self.__cache.removeHashPrefixIndices( + responseThreatList, removal["rawIndices"]["indices"]) + QCoreApplication.processEvents() + for addition in response.get("additions", []): + hashPrefixList = HashPrefixList( + addition["rawHashes"]["prefixSize"], + base64.b64decode(addition["rawHashes"]["rawHashes"])) + self.__cache.populateHashPrefixList(responseThreatList, + hashPrefixList) + QCoreApplication.processEvents() + expectedChecksum = base64.b64decode(response["checksum"]["sha256"]) + if self.__verifyThreatListChecksum(responseThreatList, + expectedChecksum): + self.__cache.updateThreatListClientState( + responseThreatList, response["newClientState"]) + else: + return False, \ + self.tr("Local cache checksum does not match the server." + " Consider cleaning the cache. Threat update has" + " been aborted.") + + return True, "" + + def __verifyThreatListChecksum(self, threatList, remoteChecksum): + """ + Private method to verify the local checksum of a threat list with the + checksum of the safe browsing server. + + @param threatList threat list to calculate checksum for + @type ThreatList + @param remoteChecksum SHA256 checksum as reported by the Google server + @type bytes + @return flag indicating equality + @rtype bool + """ + localChecksum = self.__cache.hashPrefixListChecksum(threatList) + return remoteChecksum == localChecksum + + def fullCacheCleanup(self): + """ + Public method to clean up the cache completely. + """ + self.__cache.prepareCacheDb() + + def showSafeBrowsingDialog(self): + """ + Public slot to show the safe browsing management dialog. + """ + if self.__gsbDialog is None: + from WebBrowser.WebBrowserWindow import WebBrowserWindow + from .SafeBrowsingDialog import SafeBrowsingDialog + self.__gsbDialog = SafeBrowsingDialog( + self, parent=WebBrowserWindow.mainWindow()) + + self.__gsbDialog.show() + + def lookupUrl(self, url): + """ + Public method to lookup an URL. + + @param url URL to be checked + @type str or QUrl + @return list of threat lists the URL was found in + @rtype list of ThreatList + @exception ValueError raised for an invalid URL + """ + if self.__enabled: + if isinstance(url, QUrl): + urlStr = url.toString().strip() + else: + urlStr = url.strip() + + if not urlStr: + raise ValueError("Empty URL given.") + + urlHashes = SafeBrowsingUrl(urlStr).hashes() + listNames = self.__lookupHashes(urlHashes) + if listNames: + return listNames + + return None + + def __lookupHashes(self, fullHashes): + """ + Private method to lookup the given hashes. + + @param fullHashes list of hashes to lookup + @type list of bytes + @return names of threat lists hashes were found in + @rtype list of ThreatList + """ + fullHashes = list(fullHashes) + cues = [toHex(fh[:4]) for fh in fullHashes] + result = [] + + matchingPrefixes = {} + matchingFullHashes = set() + isPotentialThreat = False + # Lookup hash prefixes which match full URL hash + for threatList, hashPrefix, negativeCacheExpired in \ + self.__cache.lookupHashPrefix(cues): + for fullHash in fullHashes: + if fullHash.startswith(hashPrefix): + isPotentialThreat = True + # consider hash prefix negative cache as expired if it + # is expired in at least one threat list + matchingPrefixes[hashPrefix] = matchingPrefixes.get( + hashPrefix, False) or negativeCacheExpired + matchingFullHashes.add(fullHash) + + # if none matches, url hash is clear + if not isPotentialThreat: + return [] + + # if there is non-expired full hash, URL is blacklisted + matchingExpiredThreatLists = set() + for threatList, hasExpired in self.__cache.lookupFullHashes( + matchingFullHashes): + if hasExpired: + matchingExpiredThreatLists.add(threatList) + else: + result.append(threatList) + if result: + return result + + # If there are no matching expired full hash entries and negative + # cache is still current for all prefixes, consider it safe. + if len(matchingExpiredThreatLists) == 0 and \ + sum(map(int, matchingPrefixes.values())) == 0: + return [] + + # Now it can be assumed that there are expired matching full hash + # entries and/or cache prefix entries with expired negative cache. + # Both require full hash synchronization. + self.__syncFullHashes(matchingPrefixes.keys()) + + # Now repeat full hash lookup + for threatList, hasExpired in self.__cache.lookupFullHashes( + matchingFullHashes): + if not hasExpired: + result.append(threatList) + + return result + + def __syncFullHashes(self, hashPrefixes): + """ + Private method to download full hashes matching given prefixes. + + This also updates the cache expiration timestamps. + + @param hashPrefixes list of hash prefixes to get full hashes for + @type list of bytes + """ + threatLists = self.__cache.getThreatLists() + clientStates = {} + for threatList, clientState in threatLists: + clientStates[threatList.asTuple()] = clientState + + fullHashResponses = self.__apiClient.getFullHashes( + hashPrefixes, clientStates) + + # update negative cache for each hash prefix + # store full hash with positive cache bumped up + for match in fullHashResponses["matches"]: + threatList = ThreatList.fromApiEntry(match) + hashValue = base64.b64decode(match["threat"]["hash"]) + cacheDuration = int(match["cacheDuration"].rstrip("s")) + malwareThreatType = None + for metadata in match["threatEntryMetadata"].get("entries", []): + key = base64.b64decode(metadata["key"]) + value = base64.b64decode(metadata["value"]) + if key == b"malware_threat_type": + malwareThreatType = value + if not isinstance(malwareThreatType, str): + malwareThreatType = malwareThreatType.decode() + self.__cache.storeFullHash(threatList, hashValue, cacheDuration, + malwareThreatType) + + negativeCacheDuration = int( + fullHashResponses["negativeCacheDuration"].rstrip("s")) + for prefixValue in hashPrefixes: + for threatList, clientState in threatLists: + self.__cache.updateHashPrefixExpiration( + threatList, prefixValue, negativeCacheDuration) + + @classmethod + def getIgnoreSchemes(cls): + """ + Class method to get the schemes not to be checked. + + @return list of schemes to be ignored + @rtype list of str + """ + return [ + "about", + "eric", + "qrc", + "qthelp", + "chrome", + "abp", + "file", + ] + + def getThreatMessage(self, threatType): + """ + Public method to get a warning message for the given threat type. + + @param threatType threat type to get the message for + @type str + @return threat message + @rtype str + """ + if self.__apiClient: + msg = self.__apiClient.getThreatMessage(threatType) + else: + msg = "" + + return msg + + def getThreatMessages(self, threatLists): + """ + Public method to get threat messages for the given threats. + + @param threatLists list of threat lists to get a message for + @type list of ThreatList + @return list of threat messages, one per unique threat type + @rtype list of str + """ + threatTypes = set() + for threatList in threatLists: + threatTypes.add(threatList.threatType) + + messages = [] + if self.__apiClient: + for threatType in sorted(threatTypes): + msg = self.__apiClient.getThreatMessage(threatType) + messages.append(msg) + + return messages + + def getThreatType(self, threatList): + """ + Public method to get a display string for a given threat type. + + @param threatList threat list to get display string for + @type str + @return display string + @rtype str + """ + displayString = "" + if self.__apiClient: + displayString = self.__apiClient.getThreatType( + threatList.threatType) + return displayString + + def getPlatformString(self, platformType): + """ + Public method to get the platform string for a given platform type. + + @param platformType platform type as defined in the v4 API + @type str + @return platform string + @rtype str + """ + if self.__apiClient: + return self.__apiClient.getPlatformString(platformType) + else: + return "" + + def getThreatEntryString(self, threatEntry): + """ + Public method to get the threat entry string. + + @param threatEntry threat entry type as defined in the v4 API + @type str + @return threat entry string + @rtype str + """ + if self.__apiClient: + return self.__apiClient.getThreatEntryString(threatEntry) + else: + return ""
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingUrl.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing an URL representation suitable for Google Safe Browsing. +""" + +from __future__ import unicode_literals + +try: + import urlparse # Py2 + import urllib # Py2 +except ImportError: + import urllib.parse as urllib + from urllib import parse as urlparse + +import re +import posixpath +import socket +import struct +import hashlib + +import Preferences + + +class SafeBrowsingUrl(object): + """ + Class implementing an URL representation suitable for Google Safe Browsing. + """ + # + # Modeled after the URL class of the gglsbl package. + # https://github.com/afilipovich/gglsbl + # + def __init__(self, url): + """ + Constructor + + @param url URL to be embedded + @type str + """ + self.__url = url + + def hashes(self): + """ + Public method to get the hashes of all possible permutations of the URL + in canonical form. + + @return generator for the URL hashes + @rtype generator of bytes + """ + for variant in self.permutations(self.canonical()): + urlHash = self.digest(variant) + yield urlHash + + def canonical(self): + """ + Public method to convert the URL to the canonical form. + + @return canonical form of the URL + @rtype str + """ + def fullUnescape(u): + """ + Method to recursively unescape an URL. + + @param u URL string to unescape + @type str + @return unescaped URL string + @rtype str + """ + uu = urllib.unquote(u) + if uu == u: + return uu + else: + return fullUnescape(uu) + + def quote(s): + """ + Method to quote a string. + + @param string to be quoted + @type str + @return quoted string + @rtype str + """ + safeChars = '!"$&\'()*+,-./:;<=>?@[\\]^_`{|}~' + return urllib.quote(s, safe=safeChars) + + url = self.__url.strip() + url = url.replace('\n', '').replace('\r', '').replace('\t', '') + url = url.split('#', 1)[0] + if url.startswith('//'): + url = Preferences.getWebBrowser("DefaultScheme")[:-3] + url + if len(url.split('://')) <= 1: + url = Preferences.getWebBrowser("DefaultScheme") + url + url = quote(fullUnescape(url)) + urlParts = urlparse.urlsplit(url) + if not urlParts[0]: + url = Preferences.getWebBrowser("DefaultScheme") + url + urlParts = urlparse.urlsplit(url) + protocol = urlParts.scheme + host = fullUnescape(urlParts.hostname) + path = fullUnescape(urlParts.path) + query = urlParts.query + if not query and '?' not in url: + query = None + if not path: + path = '/' + path = posixpath.normpath(path).replace('//', '/') + if path[-1] != '/': + path += '/' + port = urlParts.port + host = host.strip('.') + host = re.sub(r'\.+', '.', host).lower() + if host.isdigit(): + try: + host = socket.inet_ntoa(struct.pack("!I", int(host))) + except Exception: + pass + if host.startswith('0x') and '.' not in host: + try: + host = socket.inet_ntoa(struct.pack("!I", int(host, 16))) + except Exception: + pass + quotedPath = quote(path) + quotedHost = quote(host) + if port is not None: + quotedHost = '{0}:{1}'.format(quotedHost, port) + canonicalUrl = '{0}://{1}{2}'.format(protocol, quotedHost, quotedPath) + if query is not None: + canonicalUrl = '{0}?{1}'.format(canonicalUrl, query) + return canonicalUrl + + @staticmethod + def permutations(url): + """ + Static method to determine all permutations of host name and path + which can be applied to blacklisted URLs. + + @param url URL string to be permuted + @type str + @return generator of permuted URL strings + @type generator of str + """ + def hostPermutations(host): + """ + Method to generate the permutations of the host name. + + @param host host name + @type str + @return generator of permuted host names + @rtype generator of str + """ + if re.match(r'\d+\.\d+\.\d+\.\d+', host): + yield host + return + parts = host.split('.') + partsLen = min(len(parts), 5) + if partsLen > 4: + yield host + for i in range(partsLen - 1): + yield '.'.join(parts[i - partsLen:]) + + def pathPermutations(path): + """ + Method to generate the permutations of the path. + + @param path path to be processed + @type str + @return generator of permuted paths + @rtype generator of str + """ + yield path + query = None + if '?' in path: + path, query = path.split('?', 1) + if query is not None: + yield path + pathParts = path.split('/')[0:-1] + curPath = '' + for i in range(min(4, len(pathParts))): + curPath = curPath + pathParts[i] + '/' + yield curPath + + protocol, addressStr = urllib.splittype(url) + host, path = urllib.splithost(addressStr) + user, host = urllib.splituser(host) + host, port = urllib.splitport(host) + host = host.strip('/') + seenPermutations = set() + for h in hostPermutations(host): + for p in pathPermutations(path): + u = '{0}{1}'.format(h, p) + if u not in seenPermutations: + yield u + seenPermutations.add(u) + + @staticmethod + def digest(url): + """ + Static method to calculate the SHA256 digest of an URL string. + + @param url URL string + @type str + @return SHA256 digest of the URL string + @rtype bytes + """ + return hashlib.sha256(url.encode('utf-8')).digest()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/SafeBrowsingUtilities.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing some utilities for Google Safe Browsing. +""" + +from __future__ import unicode_literals + +import sys + +if sys.version_info < (3, 0): + def toHex(value): + """ + Public method to convert a bytes array to a hex string. + + @param value value to be converted + @type bytes + @return hex string + @rtype str + """ + return value.encode("hex") +else: + def toHex(value): + """ + Public method to convert a bytes array to a hex string. + + @param value value to be converted + @type bytes + @return hex string + @rtype str + """ + return value.hex()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/SafeBrowsing/__init__.py Fri Aug 04 18:41:50 2017 +0200 @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Package implementing the safe browsing functionality. +""" + +from __future__ import unicode_literals + + +def SafeBrowsingHelp(): + """ + Module function to get some help about how to enable the Google Mail + OAuth2 service. + + @return help text + @rtype str + """ + return ( + "<h2>Steps to get a Google Safe Browsing API key</h2>" + "<p>In order to use Google Safe Browsing you need a Google Account," + " a Google Developer Console project, and an API key. You also need" + " to activate the Safe Browsing APIs for use with your project.</p>" + "<ol>" + "<li>Google Account<br/>You need a Google Account in order to create" + " a project. If you don't already have an account, sign up at" + " <a href='https://accounts.google.com/SignUp'>Create your Google" + " Account</a>.</li>" + "<li>Developer Console Project<br/>You need a Google Developer Console" + " project in order to create an API key. If you don't already have a" + " project, see" + " <a href='https://support.google.com/cloud/answer/6251787?hl=en'>" + "Create, shut down, and restore projects</a>.</li>" + "<li>API Key<br/>You need an API key to access the Safe Browsing APIs." + " An API key authenticates you as an API user and allows you to" + " interact with the APIs. To set up an API key, see " + "<a href='https://support.google.com/cloud/answer/6158862" + "?hl=en&ref_topic=6262490'>Setting up API keys</a>. Your new API key" + " appears in a table. Copy and paste this key into the line edit above" + " the button used to show this help.</li>" + "<li>Activate the API<br/>Finally, you need to activate the Safe" + " Browsing API for use with your project. To learn how to do this, see" + " <a href='https://support.google.com/cloud/answer/6158841?hl=en'>" + "Activate and deactivate APIs</a>. The API to enable is referred to as" + " "Google Safe Browsing API".</li>" + "</ol>" + )
--- a/WebBrowser/UrlBar/UrlBar.py Fri Aug 04 18:29:30 2017 +0200 +++ b/WebBrowser/UrlBar/UrlBar.py Fri Aug 04 18:41:50 2017 +0200 @@ -13,7 +13,7 @@ except NameError: pass -from PyQt5.QtCore import pyqtSlot, Qt, QPointF, QUrl, QDateTime, QTimer +from PyQt5.QtCore import pyqtSlot, Qt, QPointF, QUrl, QDateTime, QTimer, QPoint from PyQt5.QtGui import QColor, QPalette, QLinearGradient, QIcon from PyQt5.QtWidgets import QDialog, QApplication from PyQt5.QtWebEngineWidgets import QWebEnginePage @@ -23,7 +23,10 @@ from WebBrowser.WebBrowserWindow import WebBrowserWindow +from WebBrowser.SafeBrowsing.SafeBrowsingLabel import SafeBrowsingLabel + from .FavIconLabel import FavIconLabel + import UI.PixmapCache import Preferences @@ -51,6 +54,10 @@ self.__bmInactiveIcon = QIcon( self.__bmActiveIcon.pixmap(16, 16, QIcon.Disabled)) + self.__safeBrowsingLabel = SafeBrowsingLabel(self) + self.addWidget(self.__safeBrowsingLabel, E5LineEdit.LeftSide) + self.__safeBrowsingLabel.setVisible(False) + self.__favicon = FavIconLabel(self) self.addWidget(self.__favicon, E5LineEdit.LeftSide) @@ -68,6 +75,7 @@ self.addWidget(self.__clearButton, E5LineEdit.RightSide) self.__clearButton.setVisible(False) + self.__safeBrowsingLabel.clicked.connect(self.__showThreatInfo) self.__bookmarkButton.clicked.connect(self.__showBookmarkInfo) self.__rssButton.clicked.connect(self.__rssClicked) self.__clearButton.clicked.connect(self.clear) @@ -95,6 +103,9 @@ self.__browser.loadProgress.connect(self.update) self.__browser.loadFinished.connect(self.__loadFinished) self.__browser.loadStarted.connect(self.__loadStarted) + + self.__browser.safeBrowsingBad.connect( + self.__safeBrowsingLabel.setThreatInfo) def browser(self): """ @@ -156,6 +167,10 @@ self.__checkBookmark() self.__bookmarkButton.setVisible(True) + self.__browserUrlChanged(self.__browser.url()) + self.__safeBrowsingLabel.setVisible( + not self.__browser.getSafeBrowsingStatus()) + if ok: QTimer.singleShot(0, self.__setRssButton) @@ -220,16 +235,19 @@ if self.__browser is not None: p = self.palette() progress = self.__browser.progress() + + if not self.__browser.getSafeBrowsingStatus(): + # malicious web site + backgroundColor = QColor(170, 0, 0) + foregroundColor = QColor(Qt.white) + elif self.__browser.url().scheme() == "https": + backgroundColor = Preferences.getWebBrowser( + "SaveUrlColor") + if progress == 0 or progress == 100: - if self.__browser.url().scheme() == "https": - backgroundColor = Preferences.getWebBrowser( - "SaveUrlColor") p.setBrush(QPalette.Base, backgroundColor) p.setBrush(QPalette.Text, foregroundColor) else: - if self.__browser.url().scheme() == "https": - backgroundColor = Preferences.getWebBrowser( - "SaveUrlColor") highlight = QApplication.palette().color(QPalette.Highlight) r = (highlight.red() + 2 * backgroundColor.red()) // 3 g = (highlight.green() + 2 * backgroundColor.green()) // 3 @@ -379,3 +397,18 @@ feeds = self.__browser.getRSS() dlg = FeedsDialog(feeds, self.__browser) dlg.exec_() + + @pyqtSlot(QPoint) + def __showThreatInfo(self, pos): + """ + Private slot to show the threat info widget. + + @param pos position to show the info at + @type QPoint + """ + threatInfo = self.__safeBrowsingLabel.getThreatInfo() + if threatInfo: + from WebBrowser.SafeBrowsing.SafeBrowsingInfoWidget import \ + SafeBrowsingInfoWidget + widget = SafeBrowsingInfoWidget(threatInfo, self.__browser) + widget.showAt(pos)
--- a/WebBrowser/WebBrowserPage.py Fri Aug 04 18:29:30 2017 +0200 +++ b/WebBrowser/WebBrowserPage.py Fri Aug 04 18:41:50 2017 +0200 @@ -14,12 +14,15 @@ except NameError: pass -from PyQt5.QtCore import pyqtSlot, QUrl, QTimer, QEventLoop, QPoint, QPointF +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QUrl, QTimer, QEventLoop, \ + QPoint, QPointF from PyQt5.QtGui import QDesktopServices from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineSettings, \ QWebEngineScript from PyQt5.QtWebChannel import QWebChannel +from E5Gui import E5MessageBox + from WebBrowser.WebBrowserWindow import WebBrowserWindow from .JavaScript.ExternalJsObject import ExternalJsObject @@ -33,6 +36,11 @@ class WebBrowserPage(QWebEnginePage): """ Class implementing an enhanced web page. + + @signal safeBrowsingAbort() emitted to indicate an abort due to a safe + browsing event + @signal safeBrowsingBad(threatType, threatMessages) emitted to indicate a + malicious web site as determined by safe browsing """ if qVersionTuple() >= (5, 7, 0): # SafeJsWorld = QWebEngineScript.ApplicationWorld @@ -40,6 +48,9 @@ else: SafeJsWorld = QWebEngineScript.MainWorld + safeBrowsingAbort = pyqtSignal() + safeBrowsingBad = pyqtSignal(str, str) + def __init__(self, parent=None): """ Constructor @@ -65,6 +76,7 @@ self.urlChanged.connect(self.__urlChanged) self.__printer = None + self.__badSite = False def acceptNavigationRequest(self, url, type_, isMainFrame): """ @@ -96,6 +108,33 @@ WebBrowserWindow.greaseMonkeyManager().downloadScript(url) return False + # Safe Browsing + self.__badSite = False + if url.scheme() not in \ + WebBrowserWindow.safeBrowsingManager().getIgnoreSchemes(): + threatLists = WebBrowserWindow.safeBrowsingManager().lookupUrl(url) + if threatLists: + threatMessages = WebBrowserWindow.safeBrowsingManager()\ + .getThreatMessages(threatLists) + res = E5MessageBox.warning( + WebBrowserWindow.getWindow(), + self.tr("Suspicuous URL detected"), + self.tr("<p>The URL <b>{0}</b> was found in the Safe" + " Browsing database.</p>{1}").format( + url.toString(), "".join(threatMessages)), + E5MessageBox.StandardButtons( + E5MessageBox.Abort | + E5MessageBox.Ignore), + E5MessageBox.Abort) + if res == E5MessageBox.Abort: + self.safeBrowsingAbort.emit() + return False + + self.__badSite = True + threatType = WebBrowserWindow.safeBrowsingManager()\ + .getThreatType(threatLists[0]) + self.safeBrowsingBad.emit(threatType, "".join(threatMessages)) + return QWebEnginePage.acceptNavigationRequest(self, url, type_, isMainFrame) @@ -374,6 +413,19 @@ self.view().mainWindow().javascriptConsole().javaScriptConsoleMessage( level, message, lineNumber, sourceId) + ########################################################################### + ## Methods below implement safe browsing related functions + ########################################################################### + + def getSafeBrowsingStatus(self): + """ + Public method to get the safe browsing status of the current page. + + @return flag indicating a safe site + @rtype bool + """ + return not self.__badSite + ################################################## ## Methods below implement compatibility functions ##################################################
--- a/WebBrowser/WebBrowserView.py Fri Aug 04 18:29:30 2017 +0200 +++ b/WebBrowser/WebBrowserView.py Fri Aug 04 18:41:50 2017 +0200 @@ -53,6 +53,10 @@ @signal search(QUrl) emitted, when a search is requested @signal zoomValueChanged(int) emitted to signal a change of the zoom value @signal faviconChanged() emitted to signal a changed web site icon + @signal safeBrowsingAbort() emitted to indicate an abort due to a safe + browsing event + @signal safeBrowsingBad(threatType, threatMessages) emitted to indicate a + malicious web site as determined by safe browsing """ sourceChanged = pyqtSignal(QUrl) forwardAvailable = pyqtSignal(bool) @@ -61,6 +65,8 @@ search = pyqtSignal(QUrl) zoomValueChanged = pyqtSignal(int) faviconChanged = pyqtSignal() + safeBrowsingAbort = pyqtSignal() + safeBrowsingBad = pyqtSignal(str, str) ZoomLevels = [ 30, 40, 50, 67, 80, 90, @@ -85,8 +91,8 @@ self.__speedDial = WebBrowserWindow.speedDial() - self.__page = WebBrowserPage(self) - self.setPage(self.__page) + self.__page = None + self.__createNewPage() self.__mw = mainWindow self.__isLoading = False @@ -137,6 +143,16 @@ self.grabGesture(Qt.PinchGesture) + def __createNewPage(self): + """ + Private method to create a new page object. + """ + self.__page = WebBrowserPage(self) + self.setPage(self.__page) + + self.__page.safeBrowsingAbort.connect(self.safeBrowsingAbort) + self.__page.safeBrowsingBad.connect(self.safeBrowsingBad) + def __setRwhvqt(self): """ Private slot to set widget that receives input events. @@ -1579,8 +1595,7 @@ @type QWebEnginePage.RenderProcessTerminationStatus """ self.page().deleteLater() - self.__page = WebBrowserPage(self) - self.setPage(self.__page) + self.__createNewPage() html = readAllFileContents(":/html/tabCrashPage.html") html = html.replace("@IMAGE@", pixmapToDataUrl( @@ -2049,3 +2064,19 @@ QUrl.fromUserInput(urlStr)) return title, urlStr, icon + + ########################################################################### + ## Methods below implement safe browsing related functions + ########################################################################### + + def getSafeBrowsingStatus(self): + """ + Public method to get the safe browsing status of the current page. + + @return flag indicating a safe site + @rtype bool + """ + if self.__page: + return self.__page.getSafeBrowsingStatus() + else: + return True
--- a/WebBrowser/WebBrowserWindow.py Fri Aug 04 18:29:30 2017 +0200 +++ b/WebBrowser/WebBrowserWindow.py Fri Aug 04 18:41:50 2017 +0200 @@ -108,6 +108,7 @@ _autoScroller = None _tabManager = None _sessionManager = None + _safeBrowsingManager = None _performingStartup = True _performingShutdown = False @@ -1757,6 +1758,23 @@ self.__showCertificateErrorsDialog) self.__actions.append(self.certificateErrorsAct) + self.safeBrowsingAct = E5Action( + self.tr('Manage Safe Browsing'), + UI.PixmapCache.getIcon("safeBrowsing.png"), + self.tr('Manage Safe Browsing...'), 0, 0, self, + 'webbrowser_manage_safe_browsing') + self.safeBrowsingAct.setStatusTip(self.tr( + 'Configure Safe Browsing and manage local cache')) + self.safeBrowsingAct.setWhatsThis(self.tr( + """<b>Manage Safe Browsing</b>""" + """<p>This opens a dialog to configure Safe Browsing and""" + """ to manage the local cache.</p>""" + )) + if not self.__initShortcutsOnly: + self.safeBrowsingAct.triggered.connect( + self.__showSafeBrowsingDialog) + self.__actions.append(self.safeBrowsingAct) + self.showDownloadManagerAct = E5Action( self.tr('Downloads'), self.tr('Downloads'), @@ -2084,6 +2102,8 @@ menu.addSeparator() menu.addAction(self.adblockAct) menu.addSeparator() + menu.addAction(self.safeBrowsingAct) + menu.addSeparator() self.__settingsMenu = menu self.__settingsMenu.aboutToShow.connect( self.__aboutToShowSettingsMenu) @@ -2205,6 +2225,8 @@ menu.addSeparator() menu.addAction(self.adblockAct) menu.addSeparator() + menu.addAction(self.safeBrowsingAct) + menu.addSeparator() menu.addMenu(self.__userAgentMenu) menu.addAction(self.userAgentManagerAct) menu.addSeparator() @@ -2946,6 +2968,8 @@ self.networkManager().shutdown() + self.safeBrowsingManager().close() + for browser in WebBrowserWindow.BrowserWindows: if browser != self: browser.close() @@ -4833,3 +4857,27 @@ Private slot to show the session manager dialog. """ self.sessionManager().showSessionManagerDialog() + + ########################################################## + ## Methods below implement safe browsing related functions + ########################################################## + + @classmethod + def safeBrowsingManager(cls): + """ + Class method to get a reference to the safe browsing interface. + + @return reference to the safe browsing manager + @rtype SafeBrowsingManager + """ + if cls._safeBrowsingManager is None: + from .SafeBrowsing.SafeBrowsingManager import SafeBrowsingManager + cls._safeBrowsingManager = SafeBrowsingManager() + + return cls._safeBrowsingManager + + def __showSafeBrowsingDialog(self): + """ + Private slot to show the safe browsing management dialog. + """ + self.safeBrowsingManager().showSafeBrowsingDialog()
--- a/eric6.e4p Fri Aug 04 18:29:30 2017 +0200 +++ b/eric6.e4p Fri Aug 04 18:41:50 2017 +0200 @@ -1404,6 +1404,15 @@ <Source>WebBrowser/QtHelp/QtHelpDocumentationSelectionDialog.py</Source> <Source>WebBrowser/QtHelp/QtHelpFiltersDialog.py</Source> <Source>WebBrowser/QtHelp/__init__.py</Source> + <Source>WebBrowser/SafeBrowsing/SafeBrowsingAPIClient.py</Source> + <Source>WebBrowser/SafeBrowsing/SafeBrowsingCache.py</Source> + <Source>WebBrowser/SafeBrowsing/SafeBrowsingDialog.py</Source> + <Source>WebBrowser/SafeBrowsing/SafeBrowsingInfoWidget.py</Source> + <Source>WebBrowser/SafeBrowsing/SafeBrowsingLabel.py</Source> + <Source>WebBrowser/SafeBrowsing/SafeBrowsingManager.py</Source> + <Source>WebBrowser/SafeBrowsing/SafeBrowsingUrl.py</Source> + <Source>WebBrowser/SafeBrowsing/SafeBrowsingUtilities.py</Source> + <Source>WebBrowser/SafeBrowsing/__init__.py</Source> <Source>WebBrowser/SearchWidget.py</Source> <Source>WebBrowser/Session/SessionManager.py</Source> <Source>WebBrowser/Session/SessionManagerDialog.py</Source> @@ -1933,6 +1942,7 @@ <Form>WebBrowser/QtHelp/QtHelpDocumentationDialog.ui</Form> <Form>WebBrowser/QtHelp/QtHelpDocumentationSelectionDialog.ui</Form> <Form>WebBrowser/QtHelp/QtHelpFiltersDialog.ui</Form> + <Form>WebBrowser/SafeBrowsing/SafeBrowsingDialog.ui</Form> <Form>WebBrowser/SearchWidget.ui</Form> <Form>WebBrowser/Session/SessionManagerDialog.ui</Form> <Form>WebBrowser/SiteInfo/SiteInfoDialog.ui</Form>