From a158a21ce80ddc38d55da5bbad03baa1f0e468b9 Mon Sep 17 00:00:00 2001 From: Jan Mach <jan.mach@cesnet.cz> Date: Wed, 26 Jun 2019 12:19:56 +0200 Subject: [PATCH] Huge improvements in additional object data services. * Implemented brnad new PassiveDNS data service and pluggable module for Hawat. * Implemented support for HTML snippet endpoints. * Implemented basic framework for asynchronous fetching additional data for IP addresses in IDEA event detail view. * Implemented fetching of additional data for IP addresses. (Redmine issue: #5278) --- Gruntfile.js | 8 + conf/core/services.json.conf | 8 + conf/mentat-hawat.py.conf | 1 + lib/hawat/app.py | 20 +- lib/hawat/base.py | 144 +++++++--- .../blueprints/design/static/css/hawat.css | 52 ++++ .../design/static/js/hawat-jquery.js | 111 ++++++++ .../blueprints/design/templates/_layout.html | 19 +- .../design/templates/_macros_site.html | 31 +- .../design/templates/spt_flashmessage.html | 4 + lib/hawat/blueprints/dnsr/__init__.py | 101 +++++-- lib/hawat/blueprints/dnsr/forms.py | 4 +- .../dnsr/templates/dnsr/search.html | 37 ++- .../templates/dnsr/spt_label_hostnames.html | 36 +++ .../events/templates/events/search.html | 86 +++--- .../events/templates/events/show.html | 139 +++++++-- lib/hawat/blueprints/geoip/__init__.py | 152 ++++++++-- lib/hawat/blueprints/geoip/forms.py | 2 +- .../geoip/templates/geoip/search.html | 33 +-- .../geoip/templates/geoip/spt_label_asn.html | 35 +++ .../geoip/templates/geoip/spt_label_city.html | 56 ++++ lib/hawat/blueprints/nerd/__init__.py | 105 +++++-- lib/hawat/blueprints/nerd/forms.py | 4 +- .../nerd/templates/nerd/search.html | 75 ++++- .../templates/nerd/spt_label_reputation.html | 88 ++++++ lib/hawat/blueprints/pdnsr/__init__.py | 266 ++++++++++++++++++ lib/hawat/blueprints/pdnsr/forms.py | 67 +++++ .../pdnsr/templates/pdnsr/search.html | 92 ++++++ .../templates/pdnsr/spt_label_hostnames.html | 55 ++++ lib/hawat/blueprints/performance/__init__.py | 2 +- lib/hawat/blueprints/whois/__init__.py | 135 +++++++-- .../whois/templates/whois/search.html | 19 +- .../templates/whois/spt_label_abuse.html | 37 +++ lib/hawat/config.py | 2 + lib/hawat/const.py | 15 +- lib/hawat/forms.py | 38 +++ lib/hawat/templates/hawat-main.js | 41 ++- lib/hawat/utils.py | 27 ++ lib/mentat/__init__.py | 2 +- lib/mentat/const.py | 8 +- lib/mentat/services/dnsr.py | 4 +- lib/mentat/services/geoip.py | 8 +- lib/mentat/services/nerd.py | 23 +- lib/mentat/services/pdnsr.py | 215 ++++++++++++++ lib/mentat/services/test_dnsr.py | 20 +- .../services/{test_nerd.py => test_pdnsr.py} | 74 +++-- package.json | 3 +- yarn.lock | 12 +- 48 files changed, 2161 insertions(+), 355 deletions(-) create mode 100644 lib/hawat/blueprints/design/templates/spt_flashmessage.html create mode 100644 lib/hawat/blueprints/dnsr/templates/dnsr/spt_label_hostnames.html create mode 100644 lib/hawat/blueprints/geoip/templates/geoip/spt_label_asn.html create mode 100644 lib/hawat/blueprints/geoip/templates/geoip/spt_label_city.html create mode 100644 lib/hawat/blueprints/nerd/templates/nerd/spt_label_reputation.html create mode 100644 lib/hawat/blueprints/pdnsr/__init__.py create mode 100644 lib/hawat/blueprints/pdnsr/forms.py create mode 100644 lib/hawat/blueprints/pdnsr/templates/pdnsr/search.html create mode 100644 lib/hawat/blueprints/pdnsr/templates/pdnsr/spt_label_hostnames.html create mode 100644 lib/hawat/blueprints/whois/templates/whois/spt_label_abuse.html create mode 100644 lib/hawat/utils.py create mode 100644 lib/mentat/services/pdnsr.py rename lib/mentat/services/{test_nerd.py => test_pdnsr.py} (53%) diff --git a/Gruntfile.js b/Gruntfile.js index 0f6c854c2..8c383754d 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -55,6 +55,14 @@ module.exports = function(grunt) { // Copy components for web user interface. webui: { files: [ + // ----- Popper.js + { + expand: true, + flatten: true, + cwd: 'node_modules/popper.js/dist/umd/', + src: './*', + dest: '<%= project_paths.web_static_dir %>vendor/popper.js/js/' + }, // ----- Bootstrap { expand: true, diff --git a/conf/core/services.json.conf b/conf/core/services.json.conf index fb0036ec2..b87c7f470 100644 --- a/conf/core/services.json.conf +++ b/conf/core/services.json.conf @@ -12,6 +12,14 @@ "lifetime": 3 }, + # + # PassiveDNS service settings. + # + "pdns": { + "base_url": "https://passivedns.cesnet.cz/", + "base_api_url": "https://passivedns.cesnet.cz/api/v0/" + }, + # # GeoIP service settings. # diff --git a/conf/mentat-hawat.py.conf b/conf/mentat-hawat.py.conf index b59cac73a..2d089c656 100644 --- a/conf/mentat-hawat.py.conf +++ b/conf/mentat-hawat.py.conf @@ -27,6 +27,7 @@ ENABLED_BLUEPRINTS = [ 'hawat.blueprints.events', 'hawat.blueprints.timeline', 'hawat.blueprints.dnsr', + 'hawat.blueprints.pdnsr', 'hawat.blueprints.geoip', #'hawat.blueprints.nerd', 'hawat.blueprints.whois', diff --git a/lib/hawat/app.py b/lib/hawat/app.py index 1db2a41d5..eb7cb00a5 100644 --- a/lib/hawat/app.py +++ b/lib/hawat/app.py @@ -25,6 +25,7 @@ __credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea import sys import traceback import os +import uuid import copy import datetime import jinja2 @@ -58,6 +59,7 @@ import hawat.db import hawat.intl import hawat.events import hawat.errors +import hawat.utils from hawat.models.user import GuiUserModel @@ -446,6 +448,18 @@ def _setup_app_core(app): default_flow_style=False ) + def get_uuid4(): + """ + Generate random UUID identifier. + """ + return uuid.uuid4() + + def get_limit_counter(limit = hawat.const.LIMIT_ADS): + """ + Get fresh instance of limit counter. + """ + return hawat.utils.LimitCounter(limit) + return dict( get_endpoints_dict = get_endpoints_dict, get_endpoint_class = get_endpoint_class, @@ -472,8 +486,10 @@ def _setup_app_core(app): current_datetime_utc = datetime.datetime.utcnow(), - include_raw = include_raw, - json_to_yaml = json_to_yaml + include_raw = include_raw, + json_to_yaml = json_to_yaml, + get_uuid4 = get_uuid4, + get_limit_counter = get_limit_counter ) class HawatJSONEncoder(flask.json.JSONEncoder): diff --git a/lib/hawat/base.py b/lib/hawat/base.py index 65f3524fa..b88d26743 100644 --- a/lib/hawat/base.py +++ b/lib/hawat/base.py @@ -24,6 +24,9 @@ Module contents * :py:class:`HawatBlueprint` * :py:class:`HTMLMixin` * :py:class:`AJAXMixin` + + * :py:class:`SnippetMixin` + * :py:class:`SQLAlchemyMixin` * :py:class:`PsycopgMixin` * :py:class:`BaseView` @@ -182,6 +185,7 @@ class HawatApp(flask.Flask): self.view_classes = {} self.resources = {} self.csag = {} + self.aods = {} @property def mconfig(self): @@ -333,49 +337,76 @@ class HawatApp(flask.Flask): """ self.resources[name] = weakref.ref(resource) - def get_csag(self, group): + def get_csag(self, group_name): """ - Return list of all registered context search actions under given group + Return list of all registered context search actions for given group name (CSAG: Context Search Action Group). - :param str group: Name of the group. + :param str group_name: Name of the group. :return: List of all registered context search actions. :rtype: list """ - return self.csag.get(group, []) + return self.csag.get(group_name, []) - def set_csag(self, group, title, view_class, params_builder): + def set_csag(self, group_name, title, view_class, params_builder): """ - Store new context search action for given group (CSAG: Context Search + Store new context search action for given group name (CSAG: Context Search Action Group). - :param str group: Name of the group. + :param str group_name: Name of the group. :param str title: Title for the search action. :param class view_class: Associated view class. :param URLParamsBuilder params_builder: URL parameter builder for this action. """ - self.csag.setdefault(group, []).append({ + self.csag.setdefault(group_name, []).append({ 'title': title, 'view': view_class, 'params': params_builder }) - def set_csag_url(self, group, title, icon, url_builder): + def set_csag_url(self, group_name, title, icon, url_builder): """ - Store new context search action for given group (CSAG: Context Search - Action Group). + Store new URL based context search action for given group name (CSAG: Context + Search Action Group). - :param str group: Name of the group. + :param str group_name: Name of the group. :param str title: Title for the search action. :param str icon: Icon for the search action. :param func url_builder: URL builder for this action. """ - self.csag.setdefault(group, []).append({ + self.csag.setdefault(group_name, []).append({ 'title': title, 'icon': icon, 'url': url_builder }) + def get_aods(self, group_name): + """ + Return list of all registered additional object data services for given + object group name (AODS: Additional Object Data Service). + + :param str group_name: Name of the group. + :return: List of all additional object data services. + :rtype: list + """ + return self.aods.get(group_name, []) + + def set_aods(self, group_name, view_class, params_builder, snippets): + """ + Store new additional object data services for given object group name + (AODS: Additional Object Data Service). + + :param str group_name: Name of the group. + :param class view_class: Associated view class. + :param list snippets: List os snippet identifiers as strings. + :param URLParamsBuilder params_builder: URL parameter builder for this action. + """ + self.aods.setdefault(group_name, []).append({ + 'view': view_class, + 'params': params_builder, + 'snippets': snippets + }) + class HawatBlueprint(flask.Blueprint): """ @@ -512,24 +543,7 @@ class AJAXMixin: custom view classess based on based on :py:class:`hawat.base.RenderableView` to provide the ability to generate JSON responses. """ - - @staticmethod - def process_response_context(response_context): - """ - Perform additional mangling with the response context before generating - the response. This method can be useful to delete some context keys, that - should not leave the server. - - :param dict response_context: Response context. - :return: Possibly updated response context. - :rtype: dict - """ - for key in ('search_form', 'item_form'): - try: - del response_context[key] - except KeyError: - pass - return response_context + KW_RESP_FLASH_MESSAGES = 'flash_messages' @staticmethod def abort(status_code, message = None): @@ -538,7 +552,10 @@ class AJAXMixin: code and optional additional message. Return response as JSON document. """ flask.abort( - hawat.errors.api_error_response(status_code, message) + hawat.errors.api_error_response( + status_code, + message + ) ) def flash(self, message, category = 'info'): # pylint: disable=locally-disabled,no-self-use @@ -550,11 +567,11 @@ class AJAXMixin: :param str category: Category of the flash message. """ self.response_context.\ - setdefault('flash_messages', {}).\ + setdefault(self.KW_RESP_FLASH_MESSAGES, {}).\ setdefault(category, []).\ append(message) - def redirect(self, target_url = None, default_url = None, exclude_url = None): # pylint: disable=locally-disabled,no-self-use + def redirect(self, target_url = None, default_url = None, exclude_url = None): """ Redirect user to different page. This implementation stores the redirection target to the JSON response. @@ -567,9 +584,27 @@ class AJAXMixin: redirect = get_redirect_target(target_url, default_url, exclude_url) ) return flask.jsonify( - self.process_response_context(self.response_context) + self.process_response_context() ) + def process_response_context(self): + """ + Perform additional mangling with the response context before generating + the response. This method can be useful to delete some context keys, that + should not leave the server. + + :param dict response_context: Response context. + :return: Possibly updated response context. + :rtype: dict + """ + # Prevent certain response context keys to appear in final response. + for key in ('search_form', 'item_form'): + try: + del self.response_context[key] + except KeyError: + pass + return self.response_context + def generate_response(self, view_template = None): # pylint: disable=locally-disabled,unused-argument """ Generate the response appropriate for this view class, in this case JSON @@ -581,12 +616,47 @@ class AJAXMixin: if flashed_messages: for category, message in flashed_messages: self.response_context.\ - setdefault('flash_messages', {}).\ + setdefault(self.KW_RESP_FLASH_MESSAGES, {}).\ setdefault(category, []).\ append(message) return flask.jsonify( - self.process_response_context(self.response_context) + self.process_response_context() + ) + + +class SnippetMixin(AJAXMixin): + """ + Mixin class enabling rendering responses as JSON documents. Use it in your + custom view classess based on based on :py:class:`hawat.base.RenderableView` + to provide the ability to generate JSON responses. + """ + KW_RESP_SNIPPETS = 'snippets' + + def generate_response(self, view_template = None): # pylint: disable=locally-disabled,unused-argument + """ + Generate the response appropriate for this view class, in this case JSON + document. + + :param str view_template: Override internally preconfigured page template. + """ + flashed_messages = flask.get_flashed_messages(with_categories = True) + if flashed_messages: + for category, message in flashed_messages: + self.response_context.\ + setdefault(self.KW_RESP_SNIPPETS, {}).\ + setdefault(self.KW_RESP_FLASH_MESSAGES, {}).\ + setdefault(category, []).\ + append( + self.render_snippet( + 'spt_flashmessage.html', + category = category, + message = message + ) + ) + + return flask.jsonify( + self.process_response_context() ) diff --git a/lib/hawat/blueprints/design/static/css/hawat.css b/lib/hawat/blueprints/design/static/css/hawat.css index 0427b5145..5a953918d 100644 --- a/lib/hawat/blueprints/design/static/css/hawat.css +++ b/lib/hawat/blueprints/design/static/css/hawat.css @@ -241,3 +241,55 @@ body { .report-message { margin-bottom: 1.5em; } + +.table-event-detail-node { + margin-bottom: 0 !important; +} +.table-event-detail-node > tbody > tr > th { + width: 3em; +} +.table-event-detail-node > tbody > tr:first-child > th, +.table-event-detail-node > tbody > tr:first-child > td { + border-top: 0 !important; +} + +/* Custom styles for additional data labels */ +.object-additional-data, +.object-additional-data > .query-result, +.object-additional-data > .query-result > .popover-hover { + display: inline-block; +} +.object-additional-data .query-result:not(:first-child), +.object-additional-data .query-result .popover-hover:not(:first-child) { + margin-left: 3px; +} +.object-additional-data .popover-content { + max-height: 20em; + overflow-y: auto; +} +.object-additional-data .popover-title, +.object-additional-data .popover-content +{ + font-size: xx-small; +} +.object-additional-data .popover-content .table +{ + margin-bottom: 0; +} +.object-additional-data .popover .popover-title { + background-color: #d9edf7; +} +.object-additional-data .popover.bottom .arrow:after { + border-bottom-color: #d9edf7; +} +/* Note: The static popover class is added dynamically by JavaScript. */ +.static-popover .popover { + position: relative; + display: block; + max-width: 100%; +} +.static-popover, +.popover-hover { + z-index: 2; +} + diff --git a/lib/hawat/blueprints/design/static/js/hawat-jquery.js b/lib/hawat/blueprints/design/static/js/hawat-jquery.js index 99867db7b..cffa6a612 100644 --- a/lib/hawat/blueprints/design/static/js/hawat-jquery.js +++ b/lib/hawat/blueprints/design/static/js/hawat-jquery.js @@ -131,7 +131,52 @@ $(function() { $('body').popover({ selector: "[data-toggle=popover]", }); + // Custom popovers implemented using Popper.js library. The Bootstrap variant + // does not support some advanced features. + $('.object-additional-data').on("mouseenter", ".popover-hover", function() { + var ref = $(this); + var popup = $($(this).attr("data-popover-content")); + popup.toggleClass("hidden static-popover"); + var popper = new Popper(ref,popup,{ + placement: 'top', + onCreate: function(data){ + popup.find('.popover').removeClass('top'); + popup.find('.popover').removeClass('bottom'); + popup.find('.popover').addClass(data.placement); + }, + onUpdate: function(data){ + popup.find('.popover').removeClass('top'); + popup.find('.popover').removeClass('bottom'); + popup.find('.popover').addClass(data.placement); + }, + modifiers: { + flip: { + behavior: ['top','bottom'] + } + } + }); + }); + $('.object-additional-data').on("mouseleave", ".popover-hover", function() { + var ref = $(this); + var popup = $($(this).attr("data-popover-content")); + popup.toggleClass("hidden static-popover"); + }); + /* + $('body').popover({ + selector: ".popover-html[data-toggle=popover]", + html: true, + trigger: 'hover click', + content: function() { + var content = $(this).attr("data-popover-content"); + return $(content).children(".popover-body").html(); + }, + title: function() { + var title = $(this).attr("data-popover-content"); + return $(title).children(".popover-heading").html(); + } + }); + */ // Callback for triggering re-render of NVD3 charts within the bootstrap tabs. // Without this function the charts on invisible tabs are rendered incorrectly. @@ -170,6 +215,72 @@ $(function() { //window.dispatchEvent(new Event('resize')); }); + + function get_additional_data(url, param, elem, ident, snippets) { + // Append element for query result and populate it with AJAX spinner. + $(elem).append( + '<div class="query-result" id="' + ident + '"><i class="fas fa-cog fa-spin" data-toggle="tooltip" title="' + ident + '"></i></div>' + ); + // Obtain reference for recently created element for query result. + elem = $(elem).children('#' + ident); + + // Perform asynchronous request. + console.log("Request URL" + url + ", snippets: " + snippets); + var jqxhr = $.get(url) + .done(function(data) { + if (data.search_result && ((Array.isArray(data.search_result) && data.search_result.length > 0) || !Array.isArray(data.search_result))) { + console.log("Result for " + ident + " query for: " + param); + console.debug(data); + $(elem).empty(); + snippets.forEach(function(snippet) { + $(elem).append(data.snippets[snippet]); + }); + } + else { + console.log("Empty result for " + ident + " query for: " + param); + console.debug(data); + // Either insert information about empty query result. + //$(element).html( + // '<i class="fas fa-check" data-toggle="tooltip" title="Empty result for ' + ident + ' query for ' + param + '"></i>' + //); + // Or remove the result element to reduce display clutter. + $(elem).remove(); + } + }) + .fail(function(data) { + console.log("Failed " + ident + " query for: " + param); + console.debug(data); + // Either insert information about error query result. + $(elem).html( + '<i class="fas fa-times" data-toggle="tooltip" title="Error for ' + ident + ' query for ' + param + '"></i>' + ); + // Or remove the result element to reduce display clutter. + //$(elem).remove(); + }) + .always(function() { + //console.log("Finished " + ident + " query for: " + param); + }); + } + + $(".object-additional-data").each(function() { + obj_type = $(this).data('object-type'); + obj_name = $(this).data('object-name'); + aods = Hawat.get_aods(obj_type); + elem = this; + console.log("Fetching additional data for object '" + obj_type + " -- " + obj_name + "': " + aods.reduce(function(sum, item) { return sum + ' ' + item.endpoint }, '')); + console.debug(aods); + + aods.forEach(function(aods_item) { + get_additional_data( + Flask.url_for(aods_item.endpoint, aods_item.params(obj_name)), + obj_name, + elem, + aods_item.ident, + aods_item.snippets + ); + }); + }); + // Special handling of '__EMPTY__' and '__ANY__' options in event search form // selects. This method stil can be improved, so that 'any' is capable of disabling // 'empty'. diff --git a/lib/hawat/blueprints/design/templates/_layout.html b/lib/hawat/blueprints/design/templates/_layout.html index 2246d4cab..eef46f550 100644 --- a/lib/hawat/blueprints/design/templates/_layout.html +++ b/lib/hawat/blueprints/design/templates/_layout.html @@ -69,6 +69,9 @@ <!-- Moment.js --> <script src="{{ url_for('design.static', filename='vendor/moment/js/moment-with-locales.min.js') }}"></script> + <!-- Popper.js --> + <script src="{{ url_for('design.static', filename='vendor/popper.js/js/popper.min.js') }}"></script> + <!-- jQuery --> <script src="{{ url_for('design.static', filename='vendor/jquery/js/jquery.min.js') }}"></script> @@ -198,7 +201,7 @@ <div id="ajax-loader"> {{ get_icon('ajax-loader') }} <hr> - <h3><strong>{{ gettext('... LOADING ...') }}</strong></h3> + <h3><strong>{{ _('... LOADING ...') }}</strong></h3> </div> </div> @@ -207,7 +210,7 @@ <div class="container-fluid"> <div id="navbar-header" class="navbar-header"> <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar"> - <span class="sr-only">{{ gettext('Toggle navigation') }}</span> + <span class="sr-only">{{ _('Toggle navigation') }}</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> @@ -224,7 +227,7 @@ {{ macros_site.render_locale_switcher() }} {%- if check_endpoint_exists('help.view') %} <li{% if request.endpoint == "help.view" %} class="active"{% endif %}> - <a href="{{ url_for('help.view') }}" data-toggle="tooltip" data-placement="bottom" title="{{ gettext('Browse help') }}">{{ get_icon('help') }}<span class="hidden-sm hidden-md"> {{ gettext('Help') }}</span></a> + <a href="{{ url_for('help.view') }}" data-toggle="tooltip" data-placement="bottom" title="{{ _('Browse help') }}">{{ get_icon('help') }}<span class="hidden-sm hidden-md"> {{ _('Help') }}</span></a> </li> {%- endif %} </ul> @@ -260,15 +263,15 @@ <!-- Footer container - BEGIN -----------------------------------------> <div class="container-fluid" id="footer"> <hr> - <span data-toggle="tooltip" title="{{ gettext('Page generated at: ') }}">{{ get_icon('clock') }}</span><span class="hidden-sm"> <em>{{ gettext('Page generated at: ') }}</em></span> {{ babel_format_datetime(current_datetime_utc) }} | - <span data-toggle="tooltip" title="{{ gettext('Page generated in: ') }}">{{ get_icon('stopwatch') }}</span><span class="hidden-sm"> <em>{{ gettext('Page generated in: ') }}</em></span> {{ get_timedelta(g.requeststart) }} + <span data-toggle="tooltip" title="{{ _('Page generated at: ') }}">{{ get_icon('clock') }}</span><span class="hidden-sm"> <em>{{ _('Page generated at: ') }}</em></span> {{ babel_format_datetime(current_datetime_utc) }} | + <span data-toggle="tooltip" title="{{ _('Page generated in: ') }}">{{ get_icon('stopwatch') }}</span><span class="hidden-sm"> <em>{{ _('Page generated in: ') }}</em></span> {{ get_timedelta(g.requeststart) }} <br> <span data-toggle="tooltip" title="{{ hawat_bversion_full }}"> - Hawat {{ hawat_bversion }} + Hawat {{ hawat_version }} </span> | - © {{ gettext('since') }} 2011 | + © {{ _('since') }} 2011 | <a data-toggle="tooltip" title="Go to official web page of CESNET organization, the NREN for Czech republic" href="https://www.cesnet.cz"> - {{ gettext('CESNET, a.l.e.') }} + {{ _('CESNET, a.l.e.') }} </a> | <a data-toggle="tooltip" title="Go to official web page of CESNET-CERTS, the CSIRT for CESNET2 network" href="http://csirt.cesnet.cz"> CESNET-CERTS diff --git a/lib/hawat/blueprints/design/templates/_macros_site.html b/lib/hawat/blueprints/design/templates/_macros_site.html index deaaeaee2..49d1245df 100644 --- a/lib/hawat/blueprints/design/templates/_macros_site.html +++ b/lib/hawat/blueprints/design/templates/_macros_site.html @@ -473,28 +473,29 @@ for example for keeping the table columns in the result from bloating up. string empty_title: Title to be displayed in case the item_list is empty. string empty_icon: Icon to be displayed in case the item_list is empty. + bool add_newline: Add newline after each widget. -#} -{%- macro _render_widget_csag(csag_group, item_list, mark_list = None, align_right = False, separate_dropdown = False, without_label = False, as_code = False, item_limit = 0, empty_title = _('-- unassigned --'), empty_icon = 'unassigned') %} - {%- if item_list %} - {%- set tmp_csag_list = get_csag(csag_group) %} - {%- for subitem in item_list %} +{%- macro _render_widget_csag(csag_group, item_list, mark_list = None, align_right = False, separate_dropdown = False, without_label = False, as_code = False, item_limit = 0, empty_title = _('-- unassigned --'), empty_icon = 'unassigned', add_newline = False) %} + {%- if item_list -%} + {%- set tmp_csag_list = get_csag(csag_group) -%} + {%- for subitem in item_list -%} {#- Limit number of items from item_list for which to generate CSAG widget. -#} - {%- if item_limit and loop.index > item_limit %} - {%- if loop.index0 == item_limit %} + {%- if item_limit and loop.index > item_limit -%} + {%- if loop.index0 == item_limit -%} <span class="underlined-tooltip" data-toggle="tooltip" title="{{ _('Please download raw message to view full list.') }}">({{ _('%(count)s more', count = loop.length - loop.index0) }})</span> - {%- endif %} - {%- else %} + {%- endif -%} + {%- else -%} {%- if separate_dropdown and not without_label %} {%- if as_code %}<code>{%- endif %}{% if mark_list and subitem.__str__() in mark_list %}<mark>{{ subitem }}</mark>{%- else %}{{ subitem }}{%- endif %}{%- if as_code %}</code>{%- endif %} - {%- endif %} + {%- endif -%} <div class="btn-group"> <button class="btn btn-default btn-xs dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> {%- if separate_dropdown and not without_label %} - <span class="caret"></span> + <i class="fas fa-fw fa-bars"></i> {%- elif not without_label %} {% if mark_list and subitem.__str__() in mark_list %}<mark>{{ subitem }}</mark>{%- else %}{{ subitem }}{%- endif %} <span class="caret"></span> {%- else %} - <span class="caret"></span> + <i class="fas fa-fw fa-bars"></i> {%- endif %} </button> <ul class="dropdown-menu{% if align_right %} dropdown-menu-right{% endif %}"> @@ -522,7 +523,8 @@ </ul> </div> {%- endif %} - {%- endfor %} + {%- if add_newline and not loop.last %}<br>{% endif %} + {% endfor %} {%- else %} <span data-toggle="tooltip" title="{{ empty_title }}">{{ get_icon(empty_icon) }}</span> {%- endif %} @@ -541,7 +543,7 @@ }} {%- endmacro %} -{%- macro render_widget_csag_address(item_list, mark_list = None, align_right = False, separate_dropdown = False, without_label = False, item_limit = 0) %} +{%- macro render_widget_csag_address(item_list, mark_list = None, align_right = False, separate_dropdown = False, without_label = False, item_limit = 0, add_newline = False) %} {{ _render_widget_csag( 'ips', @@ -553,7 +555,8 @@ True, item_limit, _('-- undisclosed --'), - 'undisclosed' + 'undisclosed', + add_newline ) }} {%- endmacro %} diff --git a/lib/hawat/blueprints/design/templates/spt_flashmessage.html b/lib/hawat/blueprints/design/templates/spt_flashmessage.html new file mode 100644 index 000000000..a5c1fadab --- /dev/null +++ b/lib/hawat/blueprints/design/templates/spt_flashmessage.html @@ -0,0 +1,4 @@ +{%- import '_macros_site.html' as macros_site with context -%} +{%- call macros_site.render_alert(category) %} +{{ message | safe }} +{%- endcall %} diff --git a/lib/hawat/blueprints/dnsr/__init__.py b/lib/hawat/blueprints/dnsr/__init__.py index 8544a0f59..498341de8 100644 --- a/lib/hawat/blueprints/dnsr/__init__.py +++ b/lib/hawat/blueprints/dnsr/__init__.py @@ -20,10 +20,27 @@ Provided endpoints ------------------ ``/dnsr/search`` - Page providing search form and displaying search results. + Endpoint providing search form for querying external DNS service and formating + result as HTML page. * *Authentication:* login required * *Methods:* ``GET`` + +``/api/dnsr/search`` + Endpoint providing API search form for querying external DNS service and + formating result as JSON document. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` + +``/snippet/dnsr/search`` + Endpoint providing API search form for querying external DNS service and + formating result as JSON document containing HTML snippets. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` """ @@ -45,7 +62,7 @@ from mentat.const import tr_ import hawat.const import hawat.db import hawat.acl -from hawat.base import HTMLMixin, AJAXMixin, RenderableView, HawatBlueprint, URLParamsBuilder +from hawat.base import HTMLMixin, AJAXMixin, SnippetMixin, RenderableView, HawatBlueprint, URLParamsBuilder from hawat.blueprints.dnsr.forms import DnsrSearchForm @@ -53,12 +70,11 @@ BLUEPRINT_NAME = 'dnsr' """Name of the blueprint as module global constant.""" -class AbstractSearchView(RenderableView): +class AbstractSearchView(RenderableView): # pylint: disable=locally-disabled,abstract-method """ - Application view providing search form for internal IP geolocation service - and appropriate result page. + Application view providing base search capabilities for DNS service. - The geolocation is implemented using :py:mod:`mentat.services.dnsr` module. + The resolving is implemented using :py:mod:`mentat.services.dnsr` module. """ authentication = True @@ -88,10 +104,17 @@ class AbstractSearchView(RenderableView): form_data = form.data dnsr_service = mentat.services.dnsr.service() self.response_context.update( - search_item = form.search.data, - search_result = dnsr_service.lookup(form.search.data), - form_data = form_data + search_item = form.search.data, + form_data = form_data ) + try: + self.response_context.update( + search_result = dnsr_service.lookup( + form.search.data + ) + ) + except Exception as exc: + self.flash(str(exc), category = 'error') self.response_context.update( search_form = form, @@ -102,8 +125,8 @@ class AbstractSearchView(RenderableView): class SearchView(HTMLMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors """ - View responsible for querying `IDEA <https://idea.cesnet.cz/en/index>`__ - event database and presenting the results in the form of HTML page. + View responsible for querying DNS service and presenting the results in the + form of HTML page. """ methods = ['GET'] @@ -115,8 +138,8 @@ class SearchView(HTMLMixin, AbstractSearchView): # pylint: disable=locally-disa class APISearchView(AJAXMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors """ - View responsible for querying `IDEA <https://idea.cesnet.cz/en/index>`__ - event database and presenting the results in the form of JSON document. + View responsible for querying DNS service and presenting the results in the + form of JSON document. """ methods = ['GET','POST'] @@ -126,6 +149,35 @@ class APISearchView(AJAXMixin, AbstractSearchView): # pylint: disable=locally-d return 'apisearch' +class SnippetSearchView(SnippetMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for querying DNS service and presenting the results in the + form of JSON document containg ready to use HTML page snippets. + """ + methods = ['GET','POST'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'sptsearch' + + def process_response_context(self): + """*Implementation* of :py:func:`hawat.base.SnippetMixin.process_response_context`.""" + super().process_response_context() + + # Make latter expressions shorter + res = self.response_context.get('search_result', False) + if res: + self.response_context.setdefault( + self.KW_RESP_SNIPPETS, + {} + )['label_hostnames'] = flask.render_template( + 'dnsr/spt_label_hostnames.html', + search_result = res + ) + return self.response_context + + #------------------------------------------------------------------------------- @@ -137,7 +189,7 @@ class DnsrBlueprint(HawatBlueprint): @classmethod def get_module_title(cls): """*Implementation* of :py:func:`hawat.base.HawatBlueprint.get_module_title`.""" - return lazy_gettext('External DNS pluggable module') + return lazy_gettext('DNS pluggable module') def register_app(self, app): """ @@ -162,11 +214,25 @@ class DnsrBlueprint(HawatBlueprint): # Register context actions provided by this module. app.set_csag( hawat.const.HAWAT_CSAG_ADDRESS, - tr_('Search for address <strong>%(name)s</strong> in DNS'), + tr_('Search for address <strong>%(name)s</strong> in DNS service'), SearchView, URLParamsBuilder({'submit': tr_('Search')}).add_rule('search') ) + # Register additional object data services provided by this module. + app.set_aods( + hawat.const.HAWAT_AODS_IP4, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search')}).add_rule('search'), + ['label_hostnames'] + ) + app.set_aods( + hawat.const.HAWAT_AODS_IP6, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search')}).add_rule('search'), + ['label_hostnames'] + ) + #------------------------------------------------------------------------------- @@ -183,7 +249,8 @@ def get_blueprint(): template_folder = 'templates' ) - hbp.register_view_class(SearchView, '/{}/search'.format(BLUEPRINT_NAME)) - hbp.register_view_class(APISearchView, '/api/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(SearchView, '/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(APISearchView, '/api/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(SnippetSearchView, '/snippet/{}/search'.format(BLUEPRINT_NAME)) return hbp diff --git a/lib/hawat/blueprints/dnsr/forms.py b/lib/hawat/blueprints/dnsr/forms.py index eb11a8084..1506d3637 100644 --- a/lib/hawat/blueprints/dnsr/forms.py +++ b/lib/hawat/blueprints/dnsr/forms.py @@ -9,7 +9,7 @@ """ -This module contains custom external DNS database search form for Hawat. +This module contains custom external DNS service search form for Hawat. """ @@ -24,7 +24,7 @@ from flask_babel import lazy_gettext class DnsrSearchForm(flask_wtf.FlaskForm): """ - Class representing DNS search form. + Class representing DNS service search form. """ search = wtforms.StringField( lazy_gettext('Search DNS:'), diff --git a/lib/hawat/blueprints/dnsr/templates/dnsr/search.html b/lib/hawat/blueprints/dnsr/templates/dnsr/search.html index 4b826d00e..fa1349efc 100644 --- a/lib/hawat/blueprints/dnsr/templates/dnsr/search.html +++ b/lib/hawat/blueprints/dnsr/templates/dnsr/search.html @@ -11,7 +11,7 @@ <div class="form-group{% if search_form.search.errors %}{{ ' has-error' }}{% endif %}"> {{ search_form.search.label(class_='sr-only') }} <div class="input-group"> - <div data-toggle="tooltip" class="input-group-addon" title="{{ _('Search DNS for:') }}">{{ get_icon('action-search') }}</div> + <div data-toggle="tooltip" class="input-group-addon" title="{{ _('Search DNS service for:') }}">{{ get_icon('action-search') }}</div> {{ search_form.search(class_='form-control', placeholder=_('Hostname, IPv4 or IPv6 address'), size='50') }} </div> @@ -33,21 +33,32 @@ <div class="row"> <div class="col-lg-12"> {%- if search_result %} - - <div class="panel panel-default"> - <div class="panel-heading"> - <h3 class="panel-title">{{ _('DNS lookup raw result') }}</h3> - </div> - <div class="panel-body"> -<pre> -{{ search_result | pprint }} -</pre> - </div> - </div> + <table class="table table-striped table-bordered table-hover table-condensed table-responsive"> + <thead> + <tr> + <th></th> + <th> + {{ _('Type') }} + </th> + <th> + {{ _('Value') }} + </th> + </tr> + </thead> + <tbody> + {%- for sritem in search_result %} + <tr> + <td>{{ loop.index }}</td> + <td><span class="label label-default">{{ sritem['type'] }}</span></td> + <td>{{ sritem['value'] }}</td> + </tr> + {%- endfor %} + </tbody> + </table> {%- else %} {%- call macros_site.render_alert('info', False) %} - {{ _('There are no records for <strong>%(item_id)s</strong> in <em>DNS</em>.', item_id = search_item) | safe }} + {{ _('There are no records for <strong>%(item_id)s</strong> in <em>DNS</em> service.', item_id = search_item) | safe }} {%- endcall %} {%- endif %} diff --git a/lib/hawat/blueprints/dnsr/templates/dnsr/spt_label_hostnames.html b/lib/hawat/blueprints/dnsr/templates/dnsr/spt_label_hostnames.html new file mode 100644 index 000000000..84540ef8a --- /dev/null +++ b/lib/hawat/blueprints/dnsr/templates/dnsr/spt_label_hostnames.html @@ -0,0 +1,36 @@ +{%- set content_id = get_uuid4() -%} +<div class="popover-hover" role="button" data-popover-content="#po-{{ content_id }}"> + <span class="label label-default"> + {{ get_icon('module-dnsr') }} | {{ search_result[0]['value'] }}{% if search_result|length > 1 %} ({{ _('%(cnt)d more', cnt = (search_result|length -1))}}){% endif %} + </span> + <div id="po-{{ content_id }}" class="hidden"> + <div class="popover top"> + <div class="arrow"></div> + <h3 class="popover-title"> + {{ get_icon('module-dnsr') }} {{ _('DNS') }} - {{ _('Hostname resolving') }} + <span class="pull-right"> + <span data-toggle="tooltip" title="{{ _('Query time: ') }} {{ get_timedelta(g.requeststart) }}"> + {{ get_icon('stopwatch') }} + </span> + </span> + </h3> + <div class="popover-content"> + <table class="table table-condensed"> + <tr> + <th></th> + <th>{{ _('Type') }}</th> + <th>{{ _('Value') }}</th> + </tr> + {%- for sritem in search_result %} + <tr> + <td>{{ loop.index }}</td> + <td><span class="label label-default">{{ sritem['type'] }}</span></td> + <td>{{ sritem['value'] }}</td> + </tr> + {%- endfor %} + </table> + </div> + </div> + </div> +</div> + diff --git a/lib/hawat/blueprints/events/templates/events/search.html b/lib/hawat/blueprints/events/templates/events/search.html index 6d182dde3..06bc4c3b7 100644 --- a/lib/hawat/blueprints/events/templates/events/search.html +++ b/lib/hawat/blueprints/events/templates/events/search.html @@ -12,20 +12,20 @@ <hr> <form method="GET" class="form-horizontal" id="form-events-simple" action="{{ url_for(request.endpoint) }}"> <div> - <div class="btn-toolbar" role="toolbar" aria-label="{{ gettext('Toolbar for enabling/disabling event search form fields.') }}"> + <div class="btn-toolbar" role="toolbar" aria-label="{{ _('Toolbar for enabling/disabling event search form fields.') }}"> <div class="btn-group" role="group"> <div class="btn-group" role="group"> {#- Detect toggle button class #} {%- set tglbtncls = in_query_params(request.args, ['dt_from', 'dt_to'], ' btn-success active', ' btn-default', ' btn-success active') %} <button type="button" class="toggle-search-form btn{{ tglbtncls }}" id="switch-form-group-detecttime" data-toggle-form-group="form-group-detecttime" data-disable-form-group="form-group-storagetime" data-toggle="button" aria-pressed="false" autocomplete="off"> - {{ gettext('DetectTime') }} + {{ _('DetectTime') }} </button> </div> <div class="btn-group" role="group"> {#- Detect toggle button class #} {%- set tglbtncls = in_query_params(request.args, ['st_from', 'st_to'], ' btn-success active', ' btn-default', ' btn-default') %} <button type="button" class="toggle-search-form btn{{ tglbtncls }}" id="switch-form-group-storagetime" data-toggle-form-group="form-group-storagetime" data-disable-form-group="form-group-detecttime" data-toggle="button" aria-pressed="false" autocomplete="off"> - {{ gettext('StorageTime') }} + {{ _('StorageTime') }} </button> </div> </div> @@ -34,21 +34,21 @@ {#- Detect toggle button class #} {%- set tglbtncls = in_query_params(request.args, ['source_addrs', 'source_ports', 'source_types'], ' btn-success active', ' btn-default', ' btn-success active') %} <button type="button" class="toggle-search-form btn{{ tglbtncls }}" id="switch-form-group-source" data-toggle-form-group="form-group-source" data-disable-form-group="form-group-host" data-toggle="button" aria-pressed="false" autocomplete="off"> - {{ gettext('Source') }} + {{ _('Source') }} </button> </div> <div class="btn-group" role="group"> {#- Detect toggle button class #} {%- set tglbtncls = in_query_params(request.args, ['target_addrs', 'target_ports', 'target_types'], ' btn-success active', ' btn-default', ' btn-default') %} <button type="button" class="toggle-search-form btn{{ tglbtncls }}" id="switch-form-group-target" data-toggle-form-group="form-group-target" data-disable-form-group="form-group-host" data-toggle="button" aria-pressed="false" autocomplete="off"> - {{ gettext('Target') }} + {{ _('Target') }} </button> </div> <div class="btn-group" role="group"> {#- Detect toggle button class #} {%- set tglbtncls = in_query_params(request.args, ['host_addrs', 'host_ports', 'host_types'], ' btn-success active', ' btn-default', ' btn-default') %} <button type="button" class="toggle-search-form btn{{ tglbtncls }}" id="switch-form-group-host" data-toggle-form-group="form-group-host" data-disable-form-group="form-group-source,form-group-target" data-toggle="button" aria-pressed="false" autocomplete="off"> - {{ gettext('Host') }} + {{ _('Host') }} </button> </div> </div> @@ -56,14 +56,14 @@ {#- Detect toggle button class #} {%- set tglbtncls = in_query_params(request.args, ['categories', 'severities', 'groups', 'protocols', 'description'], ' btn-success active', ' btn-default', ' btn-default') %} <button type="button" class="toggle-search-form btn{{ tglbtncls }}" id="switch-form-group-event" data-toggle-form-group="form-group-event-1,form-group-event-2" data-toggle="button" aria-pressed="false" autocomplete="off"> - {{ gettext('Event') }} + {{ _('Event') }} </button> </div> <div class="btn-group" role="group"> {#- Detect toggle button class #} {%- set tglbtncls = in_query_params(request.args, ['detectors', 'detector_types'], ' btn-success active', ' btn-default', ' btn-default') %} <button type="button" class="toggle-search-form btn{{ tglbtncls }}" id="switch-form-group-detector" data-toggle-form-group="form-group-detector" data-toggle="button" aria-pressed="false" autocomplete="off"> - {{ gettext('Detector') }} + {{ _('Detector') }} </button> </div> {%- if permission_can('power') %} @@ -71,7 +71,7 @@ {#- Detect toggle button class #} {%- set tglbtnadm = in_query_params(request.args, ['inspection_errs', 'classes'], ' btn-success active', ' btn-default', ' btn-default') %} <button type="button" class="toggle-search-form btn{{ tglbtnadm }}" id="switch-form-group-admin" data-toggle-form-group="form-group-admin" data-toggle="button" aria-pressed="false" autocomplete="off"> - {{ get_icon('role-admin') }} {{ gettext('Admin') }} + {{ get_icon('role-admin') }} {{ _('Admin') }} </button> </div> {%- endif %} @@ -301,32 +301,32 @@ <hr> - <div class="btn-toolbar" role="toolbar" aria-label="{{ gettext('Search menu') }}"> - <div class="btn-group" role="group" aria-label="{{ gettext('Search menu options') }}"> + <div class="btn-toolbar" role="toolbar" aria-label="{{ _('Search menu') }}"> + <div class="btn-group" role="group" aria-label="{{ _('Search menu options') }}"> {{ g.search_form.submit(class_='btn btn-primary') }} - <a role="button" class="btn btn-default" href="{{ url_for(request.endpoint) }}">{{ gettext('Clear') }}</a> + <a role="button" class="btn btn-default" href="{{ url_for(request.endpoint) }}">{{ _('Clear') }}</a> </div> {%- if permission_can('power') %} - <div class="btn-group" role="group" aria-label="{{ gettext('Admin search menu') }}"> + <div class="btn-group" role="group" aria-label="{{ _('Admin search menu') }}"> <a data-toggle="dropdown" role="button" aria-haspopup="true" href="#" class="btn btn-default dropdown-toggle" aria-expanded="false"> - <span data-toggle="tooltip" title="{{ gettext('Admin search menu') }}"> - {{ get_icon('role-admin') }}<span class="hidden-sm"> {{ gettext('Admin') }}</span> <span class="caret"></span> + <span data-toggle="tooltip" title="{{ _('Admin search menu') }}"> + {{ get_icon('role-admin') }}<span class="hidden-sm"> {{ _('Admin') }}</span> <span class="caret"></span> </span> </a> <ul class="dropdown-menu"> <li> - <a href="{{ url_for(request.endpoint, inspection_errs = ['__ANY__'], submit = gettext('Search')) }}"> - {{ get_endpoint_icon(request.endpoint) }} {{ gettext('Search for events with any inspection errors in event database') | safe }} + <a href="{{ url_for(request.endpoint, inspection_errs = ['__ANY__'], submit = _('Search')) }}"> + {{ get_endpoint_icon(request.endpoint) }} {{ _('Search for events with any inspection errors in event database') | safe }} </a> </li> <li> - <a href="{{ url_for(request.endpoint, classes = ['__EMPTY__'], submit = gettext('Search')) }}"> - {{ get_endpoint_icon(request.endpoint) }} {{ gettext('Search for events without any classification in event database') | safe }} + <a href="{{ url_for(request.endpoint, classes = ['__EMPTY__'], submit = _('Search')) }}"> + {{ get_endpoint_icon(request.endpoint) }} {{ _('Search for events without any classification in event database') | safe }} </a> </li> <li> - <a href="{{ url_for(request.endpoint, severities = ['__EMPTY__'], submit = gettext('Search')) }}"> - {{ get_endpoint_icon(request.endpoint) }} {{ gettext('Search for events without assigned severity in event database') | safe }} + <a href="{{ url_for(request.endpoint, severities = ['__EMPTY__'], submit = _('Search')) }}"> + {{ get_endpoint_icon(request.endpoint) }} {{ _('Search for events without assigned severity in event database') | safe }} </a> </li> </ul> @@ -358,7 +358,7 @@ {%- if searched %} {%- if permission_can('power') %} {%- call macros_site.render_alert('info', False, 'role-admin') %} - <strong>{{ gettext('SQL query:') }}</strong> <code>{{ sqlquery }}</code> + <strong>{{ _('SQL query:') }}</strong> <code>{{ sqlquery }}</code> {%- endcall %} {%- endif %} @@ -369,38 +369,38 @@ <thead> <tr> <!-- - <th data-toggle="tooltip" title="{{ gettext('Index') }}"> + <th data-toggle="tooltip" title="{{ _('Index') }}"> # </th> --> {%- if in_query_params(request.args, ['st_from', 'st_to'], True, False, False) %} - <th data-toggle="tooltip" title="{{ gettext('Event storage time') }}"> - {{ gettext('Stored at') }} + <th data-toggle="tooltip" title="{{ _('Event storage time') }}"> + {{ _('Stored at') }} </th> {%- else %} - <th data-toggle="tooltip" title="{{ gettext('Event detection time') }}"> - {{ gettext('Detected at') }} + <th data-toggle="tooltip" title="{{ _('Event detection time') }}"> + {{ _('Detected at') }} </th> {%- endif %} - <th data-toggle="tooltip" title="{{ gettext('Problem sources') }}" class="hidden-xs hidden-sm"> - {{ gettext('Sources') }} + <th data-toggle="tooltip" title="{{ _('Problem sources') }}" class="hidden-xs hidden-sm"> + {{ _('Sources') }} </th> - <th data-toggle="tooltip" title="{{ gettext('Targets or victims') }}" class="hidden-xs hidden-sm hidden-md"> - {{ gettext('Targets') }} + <th data-toggle="tooltip" title="{{ _('Targets or victims') }}" class="hidden-xs hidden-sm hidden-md"> + {{ _('Targets') }} </th> - <th data-toggle="tooltip" title="{{ gettext('Event severity') }}"> - {{ gettext('Severity') }} + <th data-toggle="tooltip" title="{{ _('Event severity') }}"> + {{ _('Severity') }} </th> - <th data-toggle="tooltip" title="{{ gettext('Event categorization') }}"> - {{ gettext('Categorization') }} + <th data-toggle="tooltip" title="{{ _('Event categorization') }}"> + {{ _('Categorization') }} </th> - <th data-toggle="tooltip" title="{{ gettext('Event detector') }}"> - {{ gettext('Detector') }} + <th data-toggle="tooltip" title="{{ _('Event detector') }}"> + {{ _('Detector') }} </th> - <th data-toggle="tooltip" title="{{ gettext('Abuse groups') }}"> - {{ gettext('Groups') }} + <th data-toggle="tooltip" title="{{ _('Abuse groups') }}"> + {{ _('Groups') }} </th> - <th data-toggle="tooltip" title="{{ gettext('Contextual item actions') }}"> + <th data-toggle="tooltip" title="{{ _('Contextual item actions') }}"> {{ get_icon('actions') }} </th> </tr> @@ -452,7 +452,7 @@ ) }} {%- else %} - <span data-toggle="tooltip" title="{{ gettext('-- unassigned --') }}"> + <span data-toggle="tooltip" title="{{ _('-- unassigned --') }}"> {{ get_icon('unassigned') }} </span> {%- endif %} @@ -479,7 +479,7 @@ ) }} {%- else %} - <span data-toggle="tooltip" title="{{ gettext('-- unassigned --') }}"> + <span data-toggle="tooltip" title="{{ _('-- unassigned --') }}"> {{ get_icon('unassigned') }} </span> {%- endif %} @@ -512,7 +512,7 @@ {%- else %} {%- call macros_site.render_alert('warning', False) %} - {{ gettext('No data matches your search criteria.') }} + {{ _('No data matches your search criteria.') }} {%- endcall %} {%- endif %} diff --git a/lib/hawat/blueprints/events/templates/events/show.html b/lib/hawat/blueprints/events/templates/events/show.html index d255a79c9..2d891c042 100644 --- a/lib/hawat/blueprints/events/templates/events/show.html +++ b/lib/hawat/blueprints/events/templates/events/show.html @@ -1,7 +1,7 @@ {%- extends "_layout.html" %} {%- block content %} - +{%- set ads_limits = get_limit_counter() %} <div class="row"> <div class="col-lg-12"> @@ -33,15 +33,6 @@ </small> </p> {%- endif %} - <p> - <small> - <strong>{{ _('Event severity') }}:</strong> {{ macros_site.render_event_label_severity(item, True) }} - | - <strong>{{ _('Event detected') }}:</strong> {{ babel_format_datetime(item.get_detect_time()) }} ({{ macros_site.render_info_timeinterval(item.get_detect_time(), current_datetime_utc) }}) - | - <strong>{{ _('Event stored') }}:</strong> {{ babel_format_datetime(item.get_storage_time()) }} ({{ macros_site.render_info_timeinterval(item.get_storage_time(), current_datetime_utc) }}) - </small> - </p> </div><!-- /.col-lg-12 --> </div><!-- /.row --> @@ -265,31 +256,100 @@ <ul class="list-group"> {%- for subitem in tmpval %} <li class="list-group-item"> + <table class="table table-condensed table-event-detail-node"> {%- if 'IP4' in subitem %} - <div> - <span class="label label-default"><strong>{{ _('IP4') }}:</strong></span> {{ macros_site.render_widget_csag_address(subitem['IP4'], separate_dropdown = True, item_limit = search_widget_item_limit) }} - </div> + <tr style="border-top: 0"> + <th> + {{ _('IP4') }}: + </th> + <td> + {%- for itemaddr in subitem['IP4'] %} + {%- if loop.index < search_widget_item_limit -%} + <div> + {{ macros_site.render_widget_csag_address( + [itemaddr], + separate_dropdown = True + ) }} + {%- if ads_limits.count_and_check('{}.{}'.format(node_type[0], 'IP4')) %} + <div class="object-additional-data" data-object-type="ip4" data-object-name="{{ itemaddr }}"></div> + {%- else %} + <span data-toggle="tooltip" title="{{ _('Additional data service limit for objects of this type was reached, please use manual search options.') }}">{{ get_icon('alert-info') }}</span> + {%- endif %} + </div> + {%- elif loop.index0 == search_widget_item_limit %} + <span class="underlined-tooltip" data-toggle="tooltip" title="{{ _('Please download raw message to view full list.') }}"> + ({{ _('%(count)s more', count = loop.length - loop.index0) }}) + </span> + {%- endif %} + {%- endfor %} + </td> + </tr> {%- endif %} {%- if 'IP6' in subitem %} - <div> - <span class="label label-default"><strong>{{ _('IP6') }}:</strong></span> {{ macros_site.render_widget_csag_address(subitem['IP6'], separate_dropdown = True, item_limit = search_widget_item_limit) }} - </div> + <tr> + <th> + {{ _('IP6') }}: + </th> + <td> + {%- for itemaddr in subitem['IP6'] %} + {%- if loop.index < search_widget_item_limit -%} + <div> + {{ macros_site.render_widget_csag_address( + [itemaddr], + separate_dropdown = True + ) }} + {%- if ads_limits.count_and_check('{}.{}'.format(node_type[0], 'IP6')) %} + <div class="object-additional-data" data-object-type="ip6" data-object-name="{{ itemaddr }}"></div> + {%- else %} + <span data-toggle="tooltip" title="{{ _('Additional data service limit for objects of this type was reached, please use manual search options.') }}">{{ get_icon('alert-info') }}</span> + {%- endif %} + </div> + {%- elif loop.index0 == search_widget_item_limit %} + <span class="underlined-tooltip" data-toggle="tooltip" title="{{ _('Please download raw message to view full list.') }}"> + ({{ _('%(count)s more', count = loop.length - loop.index0) }}) + </span> + {%- endif %} + {%- endfor %} + </td> + </tr> {%- endif %} {%- if 'Port' in subitem %} - <div> - <span class="label label-default"><strong>{{ _('Port') }}:</strong></span> {{ macros_site.render_widget_csag_port(subitem['Port'], separate_dropdown = True, item_limit = search_widget_item_limit) }} - </div> + <tr> + <th> + {{ _('Port') }}: + </th> + <td> + <div> + {{ macros_site.render_widget_csag_port(subitem['Port'], separate_dropdown = True, item_limit = search_widget_item_limit) }} + </div> + </td> + </tr> {%- endif %} {%- if 'Proto' in subitem %} - <div> - <span class="label label-default"><strong>{{ _('Proto') }}:</strong></span> {{ macros_site.render_widget_csag_protocol(subitem['Proto'], separate_dropdown = True, item_limit = search_widget_item_limit) }} - </div> + <tr> + <th> + {{ _('Proto') }}: + </th> + <td> + <div> + {{ macros_site.render_widget_csag_protocol(subitem['Proto'], separate_dropdown = True, item_limit = search_widget_item_limit) }} + </div> + </td> + </tr> {%- endif %} {%- if 'Type' in subitem %} - <div> - <span class="label label-default"><strong>{{ _('Type') }}:</strong></span> {{ macros_site.render_widget_csag_hosttype(subitem['Type'], separate_dropdown = True) }} - </div> + <tr> + <th> + {{ _('Type') }}: + </th> + <td> + <div> + {{ macros_site.render_widget_csag_hosttype(subitem['Type'], separate_dropdown = True) }} + </div> + </td> + </tr> {%- endif %} + </table> </li> {%- endfor %} </ul> @@ -303,15 +363,38 @@ {%- for subitem in tmpval | reverse %} <li class="list-group-item{% if loop.first %} list-group-item-info{% endif %}"> {%- if not loop.first %}<small>{%- endif %} + <table class="table table-condensed table-event-detail-node"> {%- if 'Name' in subitem %} - <span class="label label-default"><strong>{{ _('Name') }}:</strong></span> {% if loop.first %}{{ macros_site.render_widget_csag_detector([subitem['Name']], separate_dropdown = True) }}{% else %}{{ subitem['Name'] }}{%- endif %} + <tr style="border-top: 0"> + <th> + {{ _('Name') }}: + </th> + <td> + {% if loop.first %}{{ macros_site.render_widget_csag_detector([subitem['Name']], separate_dropdown = True) }}{% else %}{{ subitem['Name'] }}{%- endif %} + </td> + </tr> {%- endif %} {%- if 'SW' in subitem %} - <span class="label label-default"><strong>{{ _('SW') }}:</strong></span> {{ subitem['SW'] | join(', ') }} + <tr> + <th> + {{ _('Software') }}: + </th> + <td> + {{ subitem['SW'] | join(', ') }} + </td> + </tr> {%- endif %} {%- if 'Type' in subitem %} - <span class="label label-default"><strong>{{ _('Type') }}:</strong></span> {{ macros_site.render_widget_csag_detectortype(subitem['Type'], separate_dropdown = True) }} + <tr> + <th> + {{ _('Type') }}: + </th> + <td> + {{ macros_site.render_widget_csag_detectortype(subitem['Type'], separate_dropdown = True) }} + </td> + </tr> {%- endif %} + </table> {%- if not loop.first %}</small>{%- endif %} </li> {%- endfor %} diff --git a/lib/hawat/blueprints/geoip/__init__.py b/lib/hawat/blueprints/geoip/__init__.py index 845c8908c..23245884f 100644 --- a/lib/hawat/blueprints/geoip/__init__.py +++ b/lib/hawat/blueprints/geoip/__init__.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +7#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- # This file is part of Mentat system (https://mentat.cesnet.cz/). @@ -22,10 +22,27 @@ Provided endpoints ------------------ ``/geoip/search`` - Page providing search form and displaying search results. + Endpoint providing search form for querying internal IP geolocation service and + formating result as HTML page. * *Authentication:* login required * *Methods:* ``GET`` + +``/api/geoip/search`` + Endpoint providing API search form for querying internal IP geolocation service + and formating result as JSON document. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` + +``/snippet/geoip/search`` + Endpoint providing API search form for querying internal IP geolocation service + and formating result as JSON document containing HTML snippets. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` """ @@ -47,7 +64,7 @@ from mentat.const import tr_ import hawat.const import hawat.db import hawat.acl -from hawat.base import HTMLMixin, RenderableView, HawatBlueprint, URLParamsBuilder +from hawat.base import HTMLMixin, AJAXMixin, SnippetMixin, RenderableView, HawatBlueprint, URLParamsBuilder from hawat.blueprints.geoip.forms import GeoipSearchForm @@ -55,33 +72,26 @@ BLUEPRINT_NAME = 'geoip' """Name of the blueprint as module global constant.""" -class SearchView(HTMLMixin, RenderableView): +class AbstractSearchView(RenderableView): # pylint: disable=locally-disabled,abstract-method """ - Application view providing search form for internal IP geolocation service - and appropriate result page. + Application view providing base search capabilities for internal IP geolocation + service. The geolocation is implemented using :py:mod:`mentat.services.geoip` module. """ - methods = ['GET'] - authentication = True authorization = [hawat.acl.PERMISSION_ANY] - @classmethod - def get_view_name(cls): - """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" - return 'search' - @classmethod def get_menu_title(cls, item = None): """*Implementation* of :py:func:`hawat.base.BaseView.get_menu_title`.""" - return lazy_gettext('Internal geoip') + return lazy_gettext('Search GeoIP') @classmethod def get_view_title(cls, item = None): """*Implementation* of :py:func:`hawat.base.BaseView.get_view_title`.""" - return lazy_gettext('Search internal geoip database') + return lazy_gettext('Search GeoIP') #--------------------------------------------------------------------------- @@ -91,24 +101,95 @@ class SearchView(HTMLMixin, RenderableView): Will be called by the *Flask* framework to service the request. """ form = GeoipSearchForm(flask.request.args, meta = {'csrf': False}) - if hawat.const.HAWAT_FORM_ACTION_SUBMIT in flask.request.args: if form.validate(): form_data = form.data - geoip_manager = mentat.services.geoip.GeoipServiceManager(flask.current_app.mconfig) - geoip_service = geoip_manager.service() + geoip_service = mentat.services.geoip.service() self.response_context.update( - search_item = form.search.data, - search_result = geoip_service.lookup(form.search.data), - form_data = form_data + search_item = form.search.data, + form_data = form_data ) + try: + self.response_context.update( + search_result = geoip_service.lookup( + form.search.data + ) + ) + except Exception as exc: + self.flash(str(exc), category = 'error') + self.response_context.update( search_form = form, request_args = flask.request.args, ) return self.generate_response() + +class SearchView(HTMLMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for searching IP geolocation service and presenting the + results in the form of HTML page. + """ + methods = ['GET'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'search' + + +class APISearchView(AJAXMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for searching IP geolocation service and presenting the + results in the form of JSON document. + """ + methods = ['GET','POST'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'apisearch' + + +class SnippetSearchView(SnippetMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for searching IP geolocation service and presenting the + results in the form of JSON document containing ready to use HTML page snippets. + """ + methods = ['GET','POST'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'sptsearch' + + def process_response_context(self): + """*Implementation* of :py:func:`hawat.base.SnippetMixin.process_response_context`.""" + super().process_response_context() + + # Make latter expressions shorter + res = self.response_context.get('search_result', False) + if res: + if res.get('city', False) and res['city'].get('ctr_code', False): + self.response_context.setdefault( + self.KW_RESP_SNIPPETS, + {} + )['label_city'] = flask.render_template( + 'geoip/spt_label_city.html', + search_result = res + ) + if res.get('asn', False) and (res['asn'].get('asn', False) or res['asn'].get('org', False)): + self.response_context.setdefault( + self.KW_RESP_SNIPPETS, + {} + )['label_asn'] = flask.render_template( + 'geoip/spt_label_asn.html', + search_result = res + ) + return self.response_context + + #------------------------------------------------------------------------------- @@ -120,7 +201,7 @@ class GeoipBlueprint(HawatBlueprint): @classmethod def get_module_title(cls): """*Implementation* of :py:func:`hawat.base.HawatBlueprint.get_module_title`.""" - return lazy_gettext('Internal geoip pluggable module') + return lazy_gettext('IP geolocation pluggable module') def register_app(self, app): """ @@ -132,22 +213,38 @@ class GeoipBlueprint(HawatBlueprint): :param hawat.base.HawatApp app: Flask application to be customize. """ + mentat.services.geoip.init(app.mconfig) + app.menu_main.add_entry( 'view', 'more.{}'.format(BLUEPRINT_NAME), position = 10, - group = lazy_gettext('Tools'), + group = lazy_gettext('Data services'), view = SearchView ) # Register context actions provided by this module. app.set_csag( hawat.const.HAWAT_CSAG_ADDRESS, - tr_('Search for address <strong>%(name)s</strong> in internal IP geolocation database'), + tr_('Search for address <strong>%(name)s</strong> in IP geolocation service'), SearchView, URLParamsBuilder({'submit': tr_('Search')}).add_rule('search') ) + # Register additional object data services provided by this module. + app.set_aods( + hawat.const.HAWAT_AODS_IP4, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search')}).add_rule('search'), + ['label_city', 'label_asn'] + ) + app.set_aods( + hawat.const.HAWAT_AODS_IP6, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search')}).add_rule('search'), + ['label_city', 'label_asn'] + ) + #------------------------------------------------------------------------------- @@ -161,10 +258,11 @@ def get_blueprint(): hbp = GeoipBlueprint( BLUEPRINT_NAME, __name__, - template_folder = 'templates', - url_prefix = '/{}'.format(BLUEPRINT_NAME) + template_folder = 'templates' ) - hbp.register_view_class(SearchView, '/search') + hbp.register_view_class(SearchView, '/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(APISearchView, '/api/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(SnippetSearchView, '/snippet/{}/search'.format(BLUEPRINT_NAME)) return hbp diff --git a/lib/hawat/blueprints/geoip/forms.py b/lib/hawat/blueprints/geoip/forms.py index ff8eab15d..ef5984718 100644 --- a/lib/hawat/blueprints/geoip/forms.py +++ b/lib/hawat/blueprints/geoip/forms.py @@ -29,7 +29,7 @@ class GeoipSearchForm(flask_wtf.FlaskForm): Class representing IP geolocation search form. """ search = wtforms.StringField( - lazy_gettext('Search internal geoip:'), + lazy_gettext('Search GeoIP:'), validators = [ wtforms.validators.DataRequired(), hawat.forms.check_ip_record diff --git a/lib/hawat/blueprints/geoip/templates/geoip/search.html b/lib/hawat/blueprints/geoip/templates/geoip/search.html index 34e4b6430..ce198ea60 100644 --- a/lib/hawat/blueprints/geoip/templates/geoip/search.html +++ b/lib/hawat/blueprints/geoip/templates/geoip/search.html @@ -11,13 +11,13 @@ <div class="form-group{% if search_form.search.errors %}{{ ' has-error' }}{% endif %}"> {{ search_form.search.label(class_='sr-only') }} <div class="input-group"> - <div data-toggle="tooltip" class="input-group-addon" title="{{ gettext('Search local IP geolocation database for:') }}">{{ get_icon('action-search') }}</div> - {{ search_form.search(class_='form-control', placeholder=gettext('IPv4 or IPv6 address'), size='50') }} + <div data-toggle="tooltip" class="input-group-addon" title="{{ _('Search local IP geolocation database for:') }}">{{ get_icon('action-search') }}</div> + {{ search_form.search(class_='form-control', placeholder=_('IPv4 or IPv6 address'), size='50') }} </div> <div class="btn-group" role="group"> {{ search_form.submit(class_='btn btn-primary') }} - <a role="button" class="btn btn-default" href="{{ url_for('geoip.search') }}">{{ gettext('Clear') }}</a> + <a role="button" class="btn btn-default" href="{{ url_for('geoip.search') }}">{{ _('Clear') }}</a> </div> </div> {{ macros_form.render_form_errors(search_form.search.errors) }} @@ -30,25 +30,23 @@ {%- if search_item %} <div class="row"> <div class="col-lg-12"> - {%- if search_result %} - {%- if search_result['asn'] %} <div class="panel panel-default"> <div class="panel-heading"> - <h3 class="panel-title">{{ gettext('ASN lookup') }}</h3> + <h3 class="panel-title">{{ _('ASN lookup') }}</h3> </div> <table class="table"> <tbody> {%- if 'asn' in search_result['asn'] %} <tr> - <th>{{ gettext('ASN number:') }}</th> + <th>{{ _('ASN number:') }}</th> <td>{{ search_result['asn']['asn'] }}</td> </tr> {%- endif %} {%- if 'org' in search_result['asn'] %} <tr> - <th>{{ gettext('Organization:') }}</th> + <th>{{ _('Organization:') }}</th> <td>{{ search_result['asn']['org'] }}</td> </tr> {%- endif %} @@ -58,7 +56,7 @@ {%- else %} {%- call macros_site.render_alert('info', False) %} - {{ gettext('There are no records for <strong>%(item_id)s</strong> in <em>ASN</em> database.', item_id = search_item) | safe }} + {{ _('There are no records for <strong>%(item_id)s</strong> in <em>ASN</em> database.', item_id = search_item) | safe }} {%- endcall %} {%- endif %} @@ -66,37 +64,37 @@ <div class="panel panel-default"> <div class="panel-heading"> - <h3 class="panel-title">{{ gettext('City lookup') }}</h3> + <h3 class="panel-title">{{ _('City lookup') }}</h3> </div> <table class="table"> <tbody> {%- if 'cnt_name' in search_result['city'] and search_result['city']['cnt_name'] %} <tr> - <th>{{ gettext('Continent:') }}</th> + <th>{{ _('Continent:') }}</th> <td>{{ search_result['city']['cnt_name'] }} ({{ search_result['city']['cnt_code'] | upper }})</td> </tr> {%- endif %} {%- if 'ctr_name' in search_result['city'] and search_result['city']['ctr_name'] %} <tr> - <th>{{ gettext('Country:') }}</th> + <th>{{ _('Country:') }}</th> <td>{{ search_result['city']['ctr_name'] }} ({{ search_result['city']['ctr_code'] | upper }}, {{ get_country_flag(search_result['city']['ctr_code']) }})</td> </tr> {%- endif %} {%- if 'cty_name' in search_result['city'] and search_result['city']['cty_name'] %} <tr> - <th>{{ gettext('City:') }}</th> + <th>{{ _('City:') }}</th> <td>{{ search_result['city']['cty_name'] }}</td> </tr> {%- endif %} {%- if 'timezone' in search_result['city'] %} <tr> - <th>{{ gettext('Timezone:') }}</th> + <th>{{ _('Timezone:') }}</th> <td>{{ search_result['city']['timezone'] }}</td> </tr> {%- endif %} {%- if 'latitude' in search_result['city'] and 'longitude' in search_result['city'] %} <tr> - <th>{{ gettext('Coordinates:') }}</th> + <th>{{ _('Coordinates:') }}</th> <td>@{{ search_result['city']['latitude'] }},{{ search_result['city']['longitude'] }}</td> </tr> {%- endif %} @@ -105,18 +103,17 @@ </div> <p> <small> - {{ gettext('Search powered by <a href="https://dev.maxmind.com/geoip/geoip2/geolite2/">GeoLite2</a> created by <a href="http://www.maxmind.com/">MaxMind</a>.') | safe }} + {{ _('Search powered by <a href="https://dev.maxmind.com/geoip/geoip2/geolite2/">GeoLite2</a> created by <a href="http://www.maxmind.com/">MaxMind</a>.') | safe }} </small> </p> {%- else %} {%- call macros_site.render_alert('info', False) %} - {{ gettext('There are no records for <strong>%(item_id)s</strong> in <em>City</em> database.', item_id = search_item) | safe }} + {{ _('There are no records for <strong>%(item_id)s</strong> in <em>City</em> database.', item_id = search_item) | safe }} {%- endcall %} {%- endif %} - {%- endif %} </div> <!-- .col-lg-12 --> </div> <!-- .row --> diff --git a/lib/hawat/blueprints/geoip/templates/geoip/spt_label_asn.html b/lib/hawat/blueprints/geoip/templates/geoip/spt_label_asn.html new file mode 100644 index 000000000..710a6a436 --- /dev/null +++ b/lib/hawat/blueprints/geoip/templates/geoip/spt_label_asn.html @@ -0,0 +1,35 @@ +{%- set content_id = get_uuid4() -%} +<div class="popover-hover" role="button" data-popover-content="#po-{{ content_id }}"> + <span class="label label-default"> + {{ get_icon('module-geoip') }} | {% if search_result['asn']['asn'] %}<strong>{{ search_result['asn']['asn'] }}</strong>{% elif search_result['asn']['org'] %}{{ search_result['asn']['org'] }}{% endif %} + </span> + <div id="po-{{ content_id }}" class="hidden"> + <div class="popover top"> + <div class="arrow"></div> + <h3 class="popover-title"> + {{ get_icon('module-geoip') }} {{ _('GeoIP') }} - {{ _('ASN resolving') }} + <span class="pull-right"> + <span data-toggle="tooltip" title="{{ _('Query time: ') }} {{ get_timedelta(g.requeststart) }}"> + {{ get_icon('stopwatch') }} + </span> + </span> + </h3> + <div class="popover-content"> + <table class="table table-condensed"> + {%- if search_result['asn']['asn'] %} + <tr> + <th>{{ _('ASN') }}</th> + <td>{{ search_result['asn']['asn'] }}</td> + </tr> + {%- endif %} + {%- if search_result['asn']['org'] %} + <tr> + <th>{{ _('Organization') }}</th> + <td>{{ search_result['asn']['org'] }}</td> + </tr> + {%- endif %} + </table> + </div> + </div> + </div> +</div> diff --git a/lib/hawat/blueprints/geoip/templates/geoip/spt_label_city.html b/lib/hawat/blueprints/geoip/templates/geoip/spt_label_city.html new file mode 100644 index 000000000..5cd3a0a9c --- /dev/null +++ b/lib/hawat/blueprints/geoip/templates/geoip/spt_label_city.html @@ -0,0 +1,56 @@ +{%- import '_macros_site.html' as macros_site with context -%} +{%- set content_id = get_uuid4() -%} +<div class="popover-hover" role="button" data-popover-content="#po-{{ content_id }}"> + <span class="label label-default"> + {{ get_icon('module-geoip') }} | {{ search_result['city']['ctr_code'] | upper }} {{ get_country_flag(search_result['city']['ctr_code']) }} + </span> + <div id="po-{{ content_id }}" class="hidden"> + <div class="popover top"> + <div class="arrow"></div> + <h3 class="popover-title"> + {{ get_icon('module-geoip') }} {{ _('GeoIP') }} - {{ _('City resolving') }} + <span class="pull-right"> + <span data-toggle="tooltip" title="{{ _('Query time: ') }} {{ get_timedelta(g.requeststart) }}"> + {{ get_icon('stopwatch') }} + </span> + </span> + </h3> + <div class="popover-content"> + <table class="table table-condensed"> + <tbody> + {%- if 'cnt_name' in search_result['city'] and search_result['city']['cnt_name'] %} + <tr> + <th>{{ _('Continent:') }}</th> + <td>{{ search_result['city']['cnt_name'] }} ({{ search_result['city']['cnt_code'] | upper }})</td> + </tr> + {%- endif %} + {%- if 'ctr_name' in search_result['city'] and search_result['city']['ctr_name'] %} + <tr> + <th>{{ _('Country:') }}</th> + <td>{{ search_result['city']['ctr_name'] }} ({{ search_result['city']['ctr_code'] | upper }}, {{ get_country_flag(search_result['city']['ctr_code']) }})</td> + </tr> + {%- endif %} + {%- if 'cty_name' in search_result['city'] and search_result['city']['cty_name'] %} + <tr> + <th>{{ _('City:') }}</th> + <td>{{ search_result['city']['cty_name'] }}</td> + </tr> + {%- endif %} + {%- if 'timezone' in search_result['city'] %} + <tr> + <th>{{ _('Timezone:') }}</th> + <td>{{ search_result['city']['timezone'] }}</td> + </tr> + {%- endif %} + {%- if 'latitude' in search_result['city'] and 'longitude' in search_result['city'] %} + <tr> + <th>{{ _('Coordinates:') }}</th> + <td>@{{ search_result['city']['latitude'] }},{{ search_result['city']['longitude'] }}</td> + </tr> + {%- endif %} + </tbody> + </table> + </div> + </div> + </div> +</div> diff --git a/lib/hawat/blueprints/nerd/__init__.py b/lib/hawat/blueprints/nerd/__init__.py index 3b66e1e60..b15450037 100644 --- a/lib/hawat/blueprints/nerd/__init__.py +++ b/lib/hawat/blueprints/nerd/__init__.py @@ -20,10 +20,27 @@ Provided endpoints ------------------ ``/nerd/search`` - Page providing search form and displaying search results. + Endpoint providing search form for querying external NERD service and formating + result as HTML page. * *Authentication:* login required * *Methods:* ``GET`` + +``/api/nerd/search`` + Endpoint providing API search form for querying external NERD service + and formating result as JSON document. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` + +``/snippet/nerd/search`` + Endpoint providing API search form for querying external NERD service + and formating result as JSON document containing HTML snippets. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` """ @@ -45,7 +62,7 @@ from mentat.const import tr_ import hawat.const import hawat.db import hawat.acl -from hawat.base import HTMLMixin, AJAXMixin, RenderableView, HawatBlueprint, URLParamsBuilder +from hawat.base import HTMLMixin, AJAXMixin, SnippetMixin, RenderableView, HawatBlueprint, URLParamsBuilder from hawat.blueprints.nerd.forms import NerdSearchForm @@ -53,12 +70,11 @@ BLUEPRINT_NAME = 'nerd' """Name of the blueprint as module global constant.""" -class AbstractSearchView(RenderableView): +class AbstractSearchView(RenderableView): # pylint: disable=locally-disabled,abstract-method """ - Application view providing search form for internal IP geolocation service - and appropriate result page. + Application view providing base search capabilities for external NERD service. - The geolocation is implemented using :py:mod:`mentat.services.geoip` module. + The querying is implemented using :py:mod:`mentat.services.nerd` module. """ authentication = True @@ -67,12 +83,12 @@ class AbstractSearchView(RenderableView): @classmethod def get_menu_title(cls, item = None): """*Implementation* of :py:func:`hawat.base.BaseView.get_menu_title`.""" - return lazy_gettext('NERD database') + return lazy_gettext('Search NERD') @classmethod def get_view_title(cls, item = None): """*Implementation* of :py:func:`hawat.base.BaseView.get_view_title`.""" - return lazy_gettext('Search NERD database') + return lazy_gettext('Search NERD') #--------------------------------------------------------------------------- @@ -88,23 +104,28 @@ class AbstractSearchView(RenderableView): form_data = form.data nerd_service = mentat.services.nerd.service() self.response_context.update( - search_item = form.search.data, - search_result = nerd_service.lookup_ip(form.search.data), - search_url = nerd_service.get_url_lookup_ip(form.search.data), - form_data = form_data + search_item = form.search.data, + search_url = nerd_service.get_url_lookup_ip(form.search.data), + form_data = form_data ) + try: + self.response_context.update( + search_result = nerd_service.lookup_ip(form.search.data) + ) + except Exception as exc: + self.flash(str(exc), category = 'error') self.response_context.update( search_form = form, - request_args = flask.request.args, + request_args = flask.request.args ) return self.generate_response() class SearchView(HTMLMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors """ - View responsible for querying `IDEA <https://idea.cesnet.cz/en/index>`__ - event database and presenting the results in the form of HTML page. + View responsible for querying external NERD service and presenting the results + in the form of HTML page. """ methods = ['GET'] @@ -116,8 +137,8 @@ class SearchView(HTMLMixin, AbstractSearchView): # pylint: disable=locally-disa class APISearchView(AJAXMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors """ - View responsible for querying `IDEA <https://idea.cesnet.cz/en/index>`__ - event database and presenting the results in the form of JSON document. + View responsible for querying external NERD service and presenting the results + in the form of JSON document. """ methods = ['GET','POST'] @@ -127,6 +148,35 @@ class APISearchView(AJAXMixin, AbstractSearchView): # pylint: disable=locally-d return 'apisearch' +class SnippetSearchView(SnippetMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for querying DNS service and presenting the results in the + form of JSON document containg ready to use HTML page snippets. + """ + methods = ['GET','POST'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'sptsearch' + + def process_response_context(self): + """*Implementation* of :py:func:`hawat.base.AJAXMixin.process_response_context`.""" + super().process_response_context() + + # Make latter expressions shorter + res = self.response_context.get('search_result', False) + if res: + self.response_context.setdefault( + self.KW_RESP_SNIPPETS, + {} + )['label_reputation'] = flask.render_template( + 'nerd/spt_label_reputation.html', + search_result = res + ) + return self.response_context + + #------------------------------------------------------------------------------- @@ -156,26 +206,32 @@ class NerdBlueprint(HawatBlueprint): app.menu_main.add_entry( 'view', 'more.{}'.format(BLUEPRINT_NAME), - position = 30, + position = 50, view = SearchView ) # Register context actions provided by this module. app.set_csag( hawat.const.HAWAT_CSAG_ADDRESS, - tr_('Search for address <strong>%(name)s</strong> locally in NERD database'), + tr_('Search for address <strong>%(name)s</strong> locally in NERD service'), SearchView, URLParamsBuilder({'submit': tr_('Search')}).add_rule('search') ) - - # Register context actions provided by this module. app.set_csag_url( hawat.const.HAWAT_CSAG_ADDRESS, - tr_('Search for address <strong>%(name)s</strong> externally in NERD database'), + tr_('Search for address <strong>%(name)s</strong> externally in NERD service'), SearchView.get_menu_icon(), mentat.services.nerd.service().get_url_lookup_ip ) + # Register additional object data services provided by this module. + app.set_aods( + hawat.const.HAWAT_AODS_IP4, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search')}).add_rule('search'), + ['label_reputation'] + ) + #------------------------------------------------------------------------------- @@ -192,7 +248,8 @@ def get_blueprint(): template_folder = 'templates' ) - hbp.register_view_class(SearchView, '/{}/search'.format(BLUEPRINT_NAME)) - hbp.register_view_class(APISearchView, '/api/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(SearchView, '/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(APISearchView, '/api/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(SnippetSearchView, '/snippet/{}/search'.format(BLUEPRINT_NAME)) return hbp diff --git a/lib/hawat/blueprints/nerd/forms.py b/lib/hawat/blueprints/nerd/forms.py index c110882db..c46222f32 100644 --- a/lib/hawat/blueprints/nerd/forms.py +++ b/lib/hawat/blueprints/nerd/forms.py @@ -29,10 +29,10 @@ class NerdSearchForm(flask_wtf.FlaskForm): Class representing NERD database search form. """ search = wtforms.StringField( - lazy_gettext('Search external NERD database:'), + lazy_gettext('Search NERD:'), validators = [ wtforms.validators.DataRequired(), - hawat.forms.check_ip_record + hawat.forms.check_ip4_record ] ) submit = wtforms.SubmitField( diff --git a/lib/hawat/blueprints/nerd/templates/nerd/search.html b/lib/hawat/blueprints/nerd/templates/nerd/search.html index 4b22359c5..d2153d145 100644 --- a/lib/hawat/blueprints/nerd/templates/nerd/search.html +++ b/lib/hawat/blueprints/nerd/templates/nerd/search.html @@ -38,17 +38,70 @@ </a> </p> {%- if search_result %} - - <div class="panel panel-default"> - <div class="panel-heading"> - <h3 class="panel-title">{{ _('NERD database IP address lookup raw result') }}</h3> - </div> - <div class="panel-body"> -<pre> -{{ search_result | pprint }} -</pre> - </div> - </div> + <table class="table table-condensed"> + <tbody> + {%- if 'rep' in search_result and search_result['rep'] %} + <tr> + <th>{{ _('Reputation') }}:</th> + <td>{{ '{:.3f}'.format(search_result['rep']) }}</td> + </tr> + {%- endif %} + {%- if 'fmp' in search_result and search_result['fmp'] and 'general' in search_result['fmp'] and search_result['fmp']['general'] %} + <tr> + <th>{{ _('FMP score') }}:</th> + <td>{{ '{:.3f}'.format(search_result['fmp']['general']) }}</td> + </tr> + {%- endif %} + {%- if 'hostname' in search_result and search_result['hostname'] %} + <tr> + <th>{{ _('Hostname') }}:</th> + <td>{{ search_result['hostname'] }}</td> + </tr> + {%- endif %} + {%- if 'asn' in search_result and search_result['asn'] %} + <tr> + <th>{{ _('ASN') }}:</th> + <td>{{ search_result['asn'] | join(', ') }}</td> + </tr> + {%- endif %} + {%- if 'geo' in search_result and search_result['geo'] and 'ctry' in search_result['geo'] and search_result['geo']['ctry'] %} + <tr> + <th>{{ _('Country') }}:</th> + <td>{{ search_result['geo']['ctry'] | upper }} {{ get_country_flag(search_result['geo']['ctry']) }}</td> + </tr> + {%- endif %} + {%- if 'bgppref' in search_result and search_result['bgppref'] %} + <tr> + <th>{{ _('BGP prefix') }}:</th> + <td>{{ search_result['bgppref'] }}</td> + </tr> + {%- endif %} + {%- if 'ipblock' in search_result and search_result['ipblock'] %} + <tr> + <th>{{ _('IP block') }}:</th> + <td>{{ search_result['ipblock'] }}</td> + </tr> + {%- endif %} + {%- if 'bl' in search_result and search_result['bl'] %} + <tr> + <th>{{ _('Blacklists') }}:</th> + <td>{% for bl in search_result['bl'] %} + <span class="label label-default">{{ bl }}</span> + {% endfor %} + </td> + </tr> + {%- endif %} + {%- if 'tags' in search_result and search_result['tags'] %} + <tr> + <th>{{ _('Tags') }}:</th> + <td>{% for tag in search_result['tags'] %} + <span class="label label-default">{{ tag['n'] }} ({{ tag['c'] }}x)</span> + {% endfor %} + </td> + </tr> + {%- endif %} + </tbody> + </table> {%- else %} {%- call macros_site.render_alert('info', False) %} diff --git a/lib/hawat/blueprints/nerd/templates/nerd/spt_label_reputation.html b/lib/hawat/blueprints/nerd/templates/nerd/spt_label_reputation.html new file mode 100644 index 000000000..ddc782b3f --- /dev/null +++ b/lib/hawat/blueprints/nerd/templates/nerd/spt_label_reputation.html @@ -0,0 +1,88 @@ +{%- import '_macros_site.html' as macros_site with context -%} +{%- set content_id = get_uuid4() -%} +<div class="popover-hover" role="button" data-popover-content="#po-{{ content_id }}"> + <span class="label label-default"> + {{ get_icon('module-nerd') }} | {{ '{:.3f}'.format(search_result['rep']) }} + </span> + <div id="po-{{ content_id }}" class="hidden"> + <div class="popover top"> + <div class="arrow"></div> + <h3 class="popover-title"> + {{ get_icon('module-nerd') }} {{ _('NERD') }} - {{ _('Reputation resolving') }} + <span class="pull-right"> + <span data-toggle="tooltip" title="{{ _('Query time: ') }} {{ get_timedelta(g.requeststart) }}"> + {{ get_icon('stopwatch') }} + </span> + </span> + </h3> + <div class="popover-content"> + <table class="table table-condensed"> + <tbody> + {%- if 'rep' in search_result and search_result['rep'] %} + <tr> + <th>{{ _('Reputation') }}:</th> + <td>{{ '{:.3f}'.format(search_result['rep']) }}</td> + </tr> + {%- endif %} + {%- if 'fmp' in search_result and search_result['fmp'] and 'general' in search_result['fmp'] and search_result['fmp']['general'] %} + <tr> + <th>{{ _('FMP score') }}:</th> + <td>{{ '{:.3f}'.format(search_result['fmp']['general']) }}</td> + </tr> + {%- endif %} + {%- if 'hostname' in search_result and search_result['hostname'] %} + <tr> + <th>{{ _('Hostname') }}:</th> + <td>{{ search_result['hostname'] }}</td> + </tr> + {%- endif %} + {%- if 'asn' in search_result and search_result['asn'] %} + <tr> + <th>{{ _('ASN') }}:</th> + <td>{{ search_result['asn'] | join(', ') }}</td> + </tr> + {%- endif %} + {%- if 'geo' in search_result and search_result['geo'] and 'ctry' in search_result['geo'] and search_result['geo']['ctry'] %} + <tr> + <th>{{ _('Country') }}:</th> + <td>{{ search_result['geo']['ctry'] | upper }} {{ get_country_flag(search_result['geo']['ctry']) }}</td> + </tr> + {%- endif %} + {%- if 'bgppref' in search_result and search_result['bgppref'] %} + <tr> + <th>{{ _('BGP prefix') }}:</th> + <td>{{ search_result['bgppref'] }}</td> + </tr> + {%- endif %} + {%- if 'ipblock' in search_result and search_result['ipblock'] %} + <tr> + <th>{{ _('IP block') }}:</th> + <td>{{ search_result['ipblock'] }}</td> + </tr> + {%- endif %} + {%- if 'bl' in search_result and search_result['bl'] %} + <tr> + <th>{{ _('Blacklists') }}:</th> + <td>{% for bl in search_result['bl'] %} + <span class="label label-default">{{ bl }}</span> + {% endfor %} + </td> + </tr> + {%- endif %} + {%- if 'tags' in search_result and search_result['tags'] %} + <tr> + <th>{{ _('Tags') }}:</th> + <td>{% for tag in search_result['tags'] %} + <span class="label label-default"> + {{ tag['n'] }} ({{ tag['c'] }}x) + </span> + {% endfor %} + </td> + </tr> + {%- endif %} + </tbody> + </table> + </div> + </div> + </div> +</div> diff --git a/lib/hawat/blueprints/pdnsr/__init__.py b/lib/hawat/blueprints/pdnsr/__init__.py new file mode 100644 index 000000000..085ffab07 --- /dev/null +++ b/lib/hawat/blueprints/pdnsr/__init__.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +#------------------------------------------------------------------------------- +# This file is part of Mentat system (https://mentat.cesnet.cz/). +# +# Copyright (C) since 2011 CESNET, z.s.p.o (http://www.ces.net/) +# Use of this source is governed by the MIT license, see LICENSE file. +#------------------------------------------------------------------------------- + + +""" +Description +----------- + +This pluggable module provides access to external PassiveDNS service. It is built +upon custom :py:mod:`mentat.services.pdnsr` module. + + +Provided endpoints +------------------ + +``/pdnsr/search`` + Endpoint providing search form for querying external PassiveDNS service and + formating result as HTML page. + + * *Authentication:* login required + * *Methods:* ``GET`` + +``/api/pdnsr/search`` + Endpoint providing API search form for querying external PassiveDNS service + and formating result as JSON document. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` + +``/snippet/pdnsr/search`` + Endpoint providing API search form for querying external PassiveDNS service + and formating result as JSON document containing HTML snippets. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` +""" + + +__author__ = "Jan Mach <jan.mach@cesnet.cz>" +__credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>" + +# +# Flask related modules. +# +import flask +from flask_babel import lazy_gettext + +# +# Custom modules. +# +import mentat.services.pdnsr +from mentat.const import tr_ + +import hawat.const +import hawat.db +import hawat.acl +from hawat.base import HTMLMixin, AJAXMixin, SnippetMixin, RenderableView, HawatBlueprint, URLParamsBuilder +from hawat.blueprints.pdnsr.forms import PDNSRSearchForm + + +BLUEPRINT_NAME = 'pdnsr' +"""Name of the blueprint as module global constant.""" + + +class AbstractSearchView(RenderableView): # pylint: disable=locally-disabled,abstract-method + """ + Application view providing base search capabilities for external PassiveDNS service. + + The querying is implemented using :py:mod:`mentat.services.pdnsr` module. + """ + authentication = True + + authorization = [hawat.acl.PERMISSION_ANY] + + @classmethod + def get_menu_title(cls, item = None): + """*Implementation* of :py:func:`hawat.base.BaseView.get_menu_title`.""" + return lazy_gettext('Search passive DNS') + + @classmethod + def get_view_title(cls, item = None): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_title`.""" + return lazy_gettext('Search passive DNS') + + #--------------------------------------------------------------------------- + + def dispatch_request(self): + """ + Mandatory interface required by the :py:func:`flask.views.View.dispatch_request`. + Will be called by the *Flask* framework to service the request. + """ + form = PDNSRSearchForm(flask.request.args, meta = {'csrf': False}) + + if hawat.const.HAWAT_FORM_ACTION_SUBMIT in flask.request.args: + if form.validate(): + form_data = form.data + pdnsr_service = mentat.services.pdnsr.service() + self.response_context.update( + search_item = form.search.data, + search_url = pdnsr_service.get_url_lookup_ip(form.search.data), + form_data = form_data + ) + + try: + self.response_context.update( + search_result = pdnsr_service.lookup_ip( + form.search.data, + form.sortby.data, + form.limit.data + ) + ) + except Exception as exc: + self.flash(str(exc), category = 'error') + + self.response_context.update( + search_form = form, + request_args = flask.request.args + ) + return self.generate_response() + + +class SearchView(HTMLMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for querying external NERD service and presenting the results + in the form of HTML page. + """ + methods = ['GET'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'search' + + +class APISearchView(AJAXMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for querying external NERD service and presenting the results + in the form of JSON document. + """ + methods = ['GET','POST'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'apisearch' + + +class SnippetSearchView(SnippetMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for querying DNS service and presenting the results in the + form of JSON document containg ready to use HTML page snippets. + """ + methods = ['GET','POST'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'sptsearch' + + def process_response_context(self): + """*Implementation* of :py:func:`hawat.base.SnippetMixin.process_response_context`.""" + super().process_response_context() + + # Make latter expressions shorter + res = self.response_context.get('search_result', False) + if res: + self.response_context.setdefault( + self.KW_RESP_SNIPPETS, + {} + )['label_hostnames'] = flask.render_template( + 'pdnsr/spt_label_hostnames.html', + search_result = res + ) + return self.response_context + + +#------------------------------------------------------------------------------- + + +class PDNSRBlueprint(HawatBlueprint): + """ + Hawat pluggable module - PassiveDNS service. + """ + + @classmethod + def get_module_title(cls): + """*Implementation* of :py:func:`hawat.base.HawatBlueprint.get_module_title`.""" + return lazy_gettext('PassiveDNS pluggable module') + + def register_app(self, app): + """ + *Callback method*. Will be called from :py:func:`hawat.base.HawatApp.register_blueprint` + method and can be used to customize the Flask application object. Possible + use cases: + + * application menu customization + + :param hawat.base.HawatApp app: Flask application to be customize. + """ + + mentat.services.pdnsr.init(app.mconfig) + + app.menu_main.add_entry( + 'view', + 'more.{}'.format(BLUEPRINT_NAME), + position = 40, + view = SearchView + ) + + # Register context actions provided by this module. + app.set_csag( + hawat.const.HAWAT_CSAG_ADDRESS, + tr_('Search for address <strong>%(name)s</strong> locally in PassiveDNS service'), + SearchView, + URLParamsBuilder({'submit': tr_('Search')}).add_rule('search') + ) + app.set_csag_url( + hawat.const.HAWAT_CSAG_ADDRESS, + tr_('Search for address <strong>%(name)s</strong> externally in PassiveDNS service'), + SearchView.get_menu_icon(), + mentat.services.pdnsr.service().get_url_lookup_ip + ) + + # Register additional object data services provided by this module. + app.set_aods( + hawat.const.HAWAT_AODS_IP4, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search'), 'sortby': 'count.desc'}).add_rule('search'), + ['label_hostnames'] + ) + app.set_aods( + hawat.const.HAWAT_AODS_IP6, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search'), 'sortby': 'count.desc'}).add_rule('search'), + ['label_hostnames'] + ) + + +#------------------------------------------------------------------------------- + + +def get_blueprint(): + """ + Mandatory interface and factory function. This function must return a valid + instance of :py:class:`hawat.base.HawatBlueprint` or :py:class:`flask.Blueprint`. + """ + + hbp = PDNSRBlueprint( + BLUEPRINT_NAME, + __name__, + template_folder = 'templates' + ) + + hbp.register_view_class(SearchView, '/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(APISearchView, '/api/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(SnippetSearchView, '/snippet/{}/search'.format(BLUEPRINT_NAME)) + + return hbp diff --git a/lib/hawat/blueprints/pdnsr/forms.py b/lib/hawat/blueprints/pdnsr/forms.py new file mode 100644 index 000000000..4ff335e89 --- /dev/null +++ b/lib/hawat/blueprints/pdnsr/forms.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +#------------------------------------------------------------------------------- +# This file is part of Mentat system (https://mentat.cesnet.cz/). +# +# Copyright (C) since 2011 CESNET, z.s.p.o (http://www.ces.net/) +# Use of this source is governed by the MIT license, see LICENSE file. +#------------------------------------------------------------------------------- + + +""" +This module contains custom external PassiveDNS database search form for Hawat. +""" + + +__author__ = "Jan Mach <jan.mach@cesnet.cz>" +__credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>" + + +import wtforms +import flask_wtf +from flask_babel import lazy_gettext + +import hawat.forms + + +class PDNSRSearchForm(flask_wtf.FlaskForm): + """ + Class representing PassiveDNS service search form. + """ + search = wtforms.StringField( + lazy_gettext('Search PassiveDNS:'), + validators = [ + wtforms.validators.DataRequired(), + hawat.forms.check_ip_record + ] + ) + sortby = wtforms.SelectField( + lazy_gettext('Sort by:'), + validators = [ + wtforms.validators.Optional() + ], + choices = [ + ('', lazy_gettext('without explicit sorting')), + ('domain.desc', lazy_gettext('by domain name descending')), + ('domain.asc', lazy_gettext('by domain name ascending')), + ('count.desc', lazy_gettext('by hit count descending')), + ('count.asc', lazy_gettext('by hit count ascending')), + ('firstseen.desc', lazy_gettext('by first seen time descending')), + ('firstseen`.asc', lazy_gettext('by first seen time ascending')), + ('lastseen.desc', lazy_gettext('by last seen time descending')), + ('lastseen.asc', lazy_gettext('by last seen time ascending')) + ], + default = '' + ) + limit = hawat.forms.SelectFieldWithNone( + lazy_gettext('Pager limit:'), + validators = [ + wtforms.validators.Optional() + ], + filters = [int], + choices = [(0, lazy_gettext('without explicit limit'))] + hawat.const.HAWAT_PAGER_LIMIT_CHOICES, + default = 0 + ) + submit = wtforms.SubmitField( + lazy_gettext('Search') + ) diff --git a/lib/hawat/blueprints/pdnsr/templates/pdnsr/search.html b/lib/hawat/blueprints/pdnsr/templates/pdnsr/search.html new file mode 100644 index 000000000..9a2a1df5c --- /dev/null +++ b/lib/hawat/blueprints/pdnsr/templates/pdnsr/search.html @@ -0,0 +1,92 @@ +{%- extends "_layout.html" %} + +{%- block content %} + + <div class="row"> + <div class="col-lg-12"> + + <div class="jumbotron" style="margin-top: 1em;"> + <h2>{{ hawat_current_view.get_view_title() }}</h2> + <form method="GET" class="form-inline" action="{{ url_for('pdnsr.search') }}"> + <div class="form-group{% if search_form.search.errors %}{{ ' has-error' }}{% endif %}"> + {{ search_form.search.label(class_='sr-only') }} + <div class="input-group"> + <div data-toggle="tooltip" class="input-group-addon" title="{{ _('Search PassiveDNS service for:') }}">{{ get_icon('action-search') }}</div> + {{ search_form.search(class_='form-control', placeholder=_('IPv4 or IPv6 address'), size='50') }} + </div> + + <div class="btn-group" role="group"> + {{ search_form.submit(class_='btn btn-primary') }} + <a role="button" class="btn btn-default" href="{{ url_for('pdnsr.search') }}"> + {{ _('Clear') }} + </a> + </div> + </div> + {{ macros_form.render_form_errors(search_form.search.errors) }} + </form> + </div> <!-- .jumbotron --> + + </div> <!-- .col-lg-12 --> + </div> <!-- .row --> + + {%- if search_item %} + <div class="row"> + <div class="col-lg-12"> + <p> + <a href="{{ search_url }}" target="_blank"> + {{ get_icon('search') }} {{ _('Search externally in PassiveDNS service') }} + </a> + </p> + {%- if search_result %} + <table class="table table-striped table-bordered table-hover table-condensed table-responsive"> + <thead> + <tr> + <th></th> + <th> + {{ get_icon('domain') }} {{ _('Domain') }} + </th> + <th> + {{ get_icon('time-from') }} {{ _('First seen') }} + </th> + <th> + {{ get_icon('time-to') }} {{ _('Last seen') }} + </th> + <th> + {{ get_icon('cnt') }} {{ _('Count') }} + </th> + </tr> + </thead> + <tbody> + {%- for sritem in search_result %} + <tr> + <td>{{ loop.index }}</td> + <td>{{ sritem.get('domain', _('-- undefined --')) }}</td> + <td>{{ sritem.get('time_first', _('-- undefined --')) }}</td> + <td>{{ sritem.get('time_last', _('-- undefined --')) }}</td> + <td>{{ sritem.get('count', _('-- undefined --')) }}</td> + </tr> + {%- endfor %} + </tbody> + </table> + {%- else %} + + {%- call macros_site.render_alert('info', False) %} + {{ _('There are no records for <strong>%(item_id)s</strong> in <em>PassiveDNS</em> service.', item_id = search_item) | safe }} + {%- endcall %} + + {%- endif %} + + </div> <!-- .col-lg-12 --> + </div> <!-- .row --> + + {%- if permission_can('developer') %} + <hr> + {{ macros_site.render_raw_var('form_data', form_data) }} + {{ macros_site.render_raw_var('search_result', search_result) }} + {{ macros_site.render_raw_var('request_args', request_args) }} + {{ macros_site.render_raw_var('query_params', query_params) }} + {%- endif %} + + {%- endif %} + +{%- endblock content %} diff --git a/lib/hawat/blueprints/pdnsr/templates/pdnsr/spt_label_hostnames.html b/lib/hawat/blueprints/pdnsr/templates/pdnsr/spt_label_hostnames.html new file mode 100644 index 000000000..06a74bc7b --- /dev/null +++ b/lib/hawat/blueprints/pdnsr/templates/pdnsr/spt_label_hostnames.html @@ -0,0 +1,55 @@ +{%- set content_id = get_uuid4() -%} +<div class="popover-hover" role="button" data-popover-content="#po-{{ content_id }}"> + <span class="label label-default"> + {{ get_icon('module-pdnsr') }} | {{ search_result[0].get('domain', _('-- undefined --')) }}{% if search_result|length > 1 %} ({{ _('%(cnt)d more', cnt = (search_result|length -1))}}){% endif %} + </span> + <div id="po-{{ content_id }}" class="hidden"> + <div class="popover top"> + <div class="arrow"></div> + <h3 class="popover-title"> + {{ get_icon('module-pdnsr') }} {{ _('PassiveDNS') }} - {{ _('Hostname resolving') }} + <span class="pull-right"> + <span data-toggle="tooltip" title="{{ _('Query time: ') }} {{ get_timedelta(g.requeststart) }}"> + {{ get_icon('stopwatch') }} + </span> + </span> + </h3> + <div class="popover-content"> + <table class="table table-condensed"> + <tr> + <th></th> + <th> + <span data-toggle="tooltip" title="{{ _('Domain') }}"> + {{ get_icon('domain') }} + </span> + </th> + <th> + <span data-toggle="tooltip" title="{{ _('First seen') }}"> + {{ get_icon('time-from') }} + </span> + </th> + <th> + <span data-toggle="tooltip" title="{{ _('Last seen') }}"> + {{ get_icon('time-to') }} + </span> + </th> + <th> + <span data-toggle="tooltip" title="{{ _('Count') }}"> + {{ get_icon('cnt') }} + </span> + </th> + </tr> + {%- for sritem in search_result %} + <tr> + <td>{{ loop.index }}</td> + <td>{{ sritem.get('domain', _('-- undefined --')) }}</td> + <td>{{ sritem.get('time_first', _('-- undefined --')) }}</td> + <td>{{ sritem.get('time_last', _('-- undefined --')) }}</td> + <td>{{ sritem.get('count', _('-- undefined --')) }}</td> + </tr> + {%- endfor %} + </table> + </div> + </div> + </div> +</div> diff --git a/lib/hawat/blueprints/performance/__init__.py b/lib/hawat/blueprints/performance/__init__.py index 11b7106f0..3520763c9 100644 --- a/lib/hawat/blueprints/performance/__init__.py +++ b/lib/hawat/blueprints/performance/__init__.py @@ -164,7 +164,7 @@ class PerformanceBlueprint(HawatBlueprint): app.menu_main.add_entry( 'view', 'more.{}'.format(BLUEPRINT_NAME), - position = 30, + position = 100, group = lazy_gettext('Status overview'), view = ViewView ) diff --git a/lib/hawat/blueprints/whois/__init__.py b/lib/hawat/blueprints/whois/__init__.py index 9d5a20b06..388e53a45 100644 --- a/lib/hawat/blueprints/whois/__init__.py +++ b/lib/hawat/blueprints/whois/__init__.py @@ -12,13 +12,13 @@ Description ----------- -This pluggable module provides access to internal whois service. It is built upon -custom :py:mod:`mentat.services.whois` module. The main purpose of the *whois* +This pluggable module provides access to local WHOIS service. It is built upon +custom :py:mod:`mentat.services.whois` module. The main purpose of the *WHOIS* service in *Mentat* system is to resolve abuse contacts for event sources to enable automated event reporting capabilities. Another use case is data access management -by abuse groups in web interface. +directed by abuse group memberships in web interface. -This module enables access to internal whois database and enables users to input +This module enables access to internal WHOIS database and enables users to input queries. The main use case scenario is a validation of target abuse contact for particular source address/network. @@ -27,10 +27,27 @@ Provided endpoints ------------------ ``/whois/search`` - Page providing search form and displaying search results. + Endpoint providing search form for querying local WHOIS service and formating + result as HTML page. * *Authentication:* login required * *Methods:* ``GET`` + +``/api/whois/search`` + Endpoint providing API search form for querying local WHOIS service + and formating result as JSON document. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` + +``/snippet/whois/search`` + Endpoint providing API search form for querying local WHOIS service + and formating result as JSON document containing HTML snippets. + + * *Authentication:* login required + * *Authorization:* any role + * *Methods:* ``GET``, ``POST`` """ @@ -53,7 +70,7 @@ from mentat.const import tr_ import hawat.db import hawat.acl -from hawat.base import HTMLMixin, RenderableView, HawatBlueprint, URLParamsBuilder +from hawat.base import HTMLMixin, AJAXMixin, SnippetMixin, RenderableView, HawatBlueprint, URLParamsBuilder from hawat.blueprints.whois.forms import WhoisSearchForm @@ -61,33 +78,26 @@ BLUEPRINT_NAME = 'whois' """Name of the blueprint as module global constant.""" -class SearchView(HTMLMixin, RenderableView): +class AbstractSearchView(RenderableView): """ - Application view providing search form for internal whois resolving service + Application view providing search form for internal WHOIS resolving service and appropriate result page. - The geolocation is implemented using :py:mod:`mentat.services.whois` module. + The resolving is implemented using :py:mod:`mentat.services.whois` module. """ - methods = ['GET'] - authentication = True authorization = [hawat.acl.PERMISSION_ANY] - @classmethod - def get_view_name(cls): - """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" - return 'search' - @classmethod def get_menu_title(cls, item = None): """*Implementation* of :py:func:`hawat.base.BaseView.get_menu_title`.""" - return lazy_gettext('Internal whois') + return lazy_gettext('Search internal whois') @classmethod def get_view_title(cls, item = None): """*Implementation* of :py:func:`hawat.base.BaseView.get_view_title`.""" - return lazy_gettext('Search internal whois database') + return lazy_gettext('Search internal whois service') #--------------------------------------------------------------------------- @@ -105,9 +115,14 @@ class SearchView(HTMLMixin, RenderableView): whois_service = whois_manager.service() self.response_context.update( search_item = form.search.data, - search_result = whois_service.lookup(form.search.data), form_data = form_data ) + try: + self.response_context.update( + search_result = whois_service.lookup(form.search.data) + ) + except Exception as exc: + self.flash(str(exc), category = 'error') self.response_context.update( search_form = form, @@ -116,18 +131,73 @@ class SearchView(HTMLMixin, RenderableView): return self.generate_response() +class SearchView(HTMLMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for querying local WHOIS service and presenting the results + in the form of HTML page. + """ + methods = ['GET'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'search' + + +class APISearchView(AJAXMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for querying local WHOIS service and presenting the results + in the form of JSON document. + """ + methods = ['GET','POST'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'apisearch' + + +class SnippetSearchView(SnippetMixin, AbstractSearchView): # pylint: disable=locally-disabled,too-many-ancestors + """ + View responsible for querying local WHOIS service and presenting the results + in the form of JSON document containg ready to use HTML page snippets. + """ + methods = ['GET','POST'] + + @classmethod + def get_view_name(cls): + """*Implementation* of :py:func:`hawat.base.BaseView.get_view_name`.""" + return 'sptsearch' + + def process_response_context(self): + """*Implementation* of :py:func:`hawat.base.AJAXMixin.process_response_context`.""" + super().process_response_context() + + # Make latter expressions shorter + res = self.response_context.get('search_result', False) + if res: + self.response_context.setdefault( + self.KW_RESP_SNIPPETS, + {} + )['label_abuse'] = flask.render_template( + 'whois/spt_label_abuse.html', + search_result = res + ) + return self.response_context + + #------------------------------------------------------------------------------- class WhoisBlueprint(HawatBlueprint): """ - Hawat pluggable module - whois resolving service. + Hawat pluggable module - WHOIS resolving service. """ @classmethod def get_module_title(cls): """*Implementation* of :py:func:`hawat.base.HawatBlueprint.get_module_title`.""" - return lazy_gettext('Internal whois pluggable module') + return lazy_gettext('Local WHOIS pluggable module') def register_app(self, app): """ @@ -149,11 +219,25 @@ class WhoisBlueprint(HawatBlueprint): # Register context actions provided by this module. app.set_csag( hawat.const.HAWAT_CSAG_ADDRESS, - tr_('Search for address <strong>%(name)s</strong> in internal whois database'), + tr_('Search for address <strong>%(name)s</strong> in local WHOIS service'), SearchView, URLParamsBuilder({'submit': tr_('Search')}).add_rule('search') ) + # Register additional object data services provided by this module. + app.set_aods( + hawat.const.HAWAT_AODS_IP4, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search')}).add_rule('search'), + ['label_abuse'] + ) + app.set_aods( + hawat.const.HAWAT_AODS_IP6, + SnippetSearchView, + URLParamsBuilder({'submit': tr_('Search')}).add_rule('search'), + ['label_abuse'] + ) + #------------------------------------------------------------------------------- @@ -167,10 +251,11 @@ def get_blueprint(): hbp = WhoisBlueprint( BLUEPRINT_NAME, __name__, - template_folder = 'templates', - url_prefix = '/{}'.format(BLUEPRINT_NAME) + template_folder = 'templates' ) - hbp.register_view_class(SearchView, '/search') + hbp.register_view_class(SearchView, '/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(APISearchView, '/api/{}/search'.format(BLUEPRINT_NAME)) + hbp.register_view_class(SnippetSearchView, '/snippet/{}/search'.format(BLUEPRINT_NAME)) return hbp diff --git a/lib/hawat/blueprints/whois/templates/whois/search.html b/lib/hawat/blueprints/whois/templates/whois/search.html index 3794c7bfb..0c26d130e 100644 --- a/lib/hawat/blueprints/whois/templates/whois/search.html +++ b/lib/hawat/blueprints/whois/templates/whois/search.html @@ -10,13 +10,13 @@ <div class="form-group{% if search_form.search.errors %}{{ ' has-error' }}{% endif %}"> {{ search_form.search.label(class_='sr-only') }} <div class="input-group"> - <div data-toggle="tooltip" class="input-group-addon" title="{{ gettext('Search local whois database for:') }}">{{ get_icon('action-search') }}</div> - {{ search_form.search(class_='form-control', placeholder=gettext('IP address, range, network or abuse email'), size='50') }} + <div data-toggle="tooltip" class="input-group-addon" title="{{ _('Search local whois service for:') }}">{{ get_icon('action-search') }}</div> + {{ search_form.search(class_='form-control', placeholder=_('IP address, range, network or abuse email'), size='50') }} </div> <div class="btn-group" role="group"> {{ search_form.submit(class_='btn btn-primary') }} - <a role="button" class="btn btn-default" href="{{ url_for('whois.search') }}">{{ gettext('Clear') }}</a> + <a role="button" class="btn btn-default" href="{{ url_for('whois.search') }}">{{ _('Clear') }}</a> </div> </div> {{ macros_form.render_form_errors(search_form.search.errors) }} @@ -33,16 +33,21 @@ {%- if search_result %} <div class="panel panel-default"> <ul class="list-group"> - {%- for item in search_result %} + {%- for item in search_result | reverse %} <li class="list-group-item"> <p class="lead"> <em>{% if 'netname' in item %}{{ item['netname'] }}: {% endif %}{{ item['network'] }}</em> </p> <p> - {{ gettext('Target abuse contacts:') }} <strong>{{ item['resolved_abuses'] | join(', ') }}</strong> + {{ _('Target abuse contacts') }}: <strong>{{ item['resolved_abuses'] | join(', ') }}</strong> </p> + {% if 'description' in item %} <p> - {% if 'description' in item %}{{ item['description'] }} {% endif %}(source: {{ item['source'] }}) + {{ _('Description') }}: {{ item['description'] }} + </p> + {% endif %} + <p> + {{ _('Source') }}: <span class="label label-default">{{ item['source'] }}</span> </p> </li> {%- endfor %} @@ -51,7 +56,7 @@ {%- else %} {%- call macros_site.render_alert('info', False) %} - {{ gettext('There are no records for <strong>%(item_id)s</strong> in <em>Whois</em> database.', item_id = search_item) | safe }} + {{ _('There are no records for <strong>%(item_id)s</strong> in <em>Whois</em> database.', item_id = search_item) | safe }} {%- endcall %} {%- endif %} diff --git a/lib/hawat/blueprints/whois/templates/whois/spt_label_abuse.html b/lib/hawat/blueprints/whois/templates/whois/spt_label_abuse.html new file mode 100644 index 000000000..6c5e60cd3 --- /dev/null +++ b/lib/hawat/blueprints/whois/templates/whois/spt_label_abuse.html @@ -0,0 +1,37 @@ +{%- set content_id = get_uuid4() -%} +<div class="popover-hover" role="button" data-popover-content="#po-{{ content_id }}"> + <span class="label label-default"> + {{ get_icon('module-whois') }} | {{ search_result[0].get('resolved_abuses', _('-- undefined --')) | join(', ') }}{% if search_result|length > 1 %} ({{ _('%(cnt)d more', cnt = (search_result|length -1))}}){% endif %} + </span> + <div id="po-{{ content_id }}" class="hidden"> + <div class="popover top"> + <div class="arrow"></div> + <h3 class="popover-title"> + {{ get_icon('module-whois') }} {{ _('Local whois') }} - {{ _('Abuse resolving') }} + <span class="pull-right"> + <span data-toggle="tooltip" title="{{ _('Query time: ') }} {{ get_timedelta(g.requeststart) }}"> + {{ get_icon('stopwatch') }} + </span> + </span> + </h3> + <div class="popover-content"> + <table class="table table-condensed"> + <tr> + <th></th> + <th>{{ _('Network') }}</th> + <th>{{ _('Netname') }}</th> + <th>{{ _('Contacts') }}</th> + </tr> + {%- for sritem in search_result %} + <tr> + <td>{{ loop.index }}</td> + <td>{% if 'network' in sritem %}{{ sritem['network'] }}{% endif %}</td> + <td>{% if 'netname' in sritem %}{{ sritem['netname'] }}{% endif %}</td> + <td>{% if 'resolved_abuses' in sritem %}{{ sritem['resolved_abuses'] | join(', ') }}{% endif %}</td> + </tr> + {%- endfor %} + </table> + </div> + </div> + </div> +</div> diff --git a/lib/hawat/config.py b/lib/hawat/config.py index d89cb9bd6..8996bba84 100644 --- a/lib/hawat/config.py +++ b/lib/hawat/config.py @@ -136,6 +136,7 @@ class Config: # pylint: disable=locally-disabled,too-few-public-methods 'hawat.blueprints.events', 'hawat.blueprints.timeline', 'hawat.blueprints.dnsr', + #'hawat.blueprints.pdnsr', 'hawat.blueprints.geoip', #'hawat.blueprints.nerd', 'hawat.blueprints.whois', @@ -269,6 +270,7 @@ class DevelopmentConfig(Config): # pylint: disable=locally-disabled,too-few-pub 'hawat.blueprints.events', 'hawat.blueprints.timeline', 'hawat.blueprints.dnsr', + #'hawat.blueprints.pdnsr', 'hawat.blueprints.geoip', #'hawat.blueprints.nerd', 'hawat.blueprints.whois', diff --git a/lib/hawat/const.py b/lib/hawat/const.py index 00fb49730..f136767fb 100644 --- a/lib/hawat/const.py +++ b/lib/hawat/const.py @@ -128,8 +128,11 @@ RESOURCE_BABEL = 'babel' RESOURCE_MIGRATE = 'migrate' """Name for the ``flask_migrate.Migrate`` object within the application resources.""" +LIMIT_ADS = 10 +"""Limit for number of objects for which to automatically fetch additional data services.""" + # -# List of all existing Hawat context action search groups. +# List of all existing Hawat context action search group names. # HAWAT_CSAG_ABUSE = 'abuses' HAWAT_CSAG_ADDRESS = 'ips' @@ -142,6 +145,12 @@ HAWAT_CSAG_PORT = 'ports' HAWAT_CSAG_PROTOCOL = 'protocols' HAWAT_CSAG_SEVERITY = 'severities' +# +# List of all existing Hawat additonal object data service group names. +# +HAWAT_AODS_IP4 = 'ip4' +HAWAT_AODS_IP6 = 'ip6' + FA_ICONS = { # @@ -187,6 +196,7 @@ FA_ICONS = { 'module-help': '<i class="fas fa-fw fa-question-circle"></i>', 'module-networks': '<i class="fas fa-fw fa-sitemap"></i>', 'module-nerd': '<i class="fas fa-fw fa-certificate"></i>', + 'module-pdnsr': '<i class="fas fa-fw fa-compass"></i>', 'module-performance': '<i class="fas fa-fw fa-chart-bar"></i>', 'module-reports': '<i class="fas fa-fw fa-newspaper"></i>', 'module-settings-reporting': '<i class="fas fa-fw fa-sliders-h"></i>', @@ -257,6 +267,9 @@ FA_ICONS = { 'calendar': '<i class="fas fa-fw fa-calendar-alt"></i>', 'stopwatch': '<i class="fas fa-fw fa-stopwatch"></i>', 'clock': '<i class="fas fa-fw fa-clock"></i>', + 'domain': '<i class="fas fa-fw fa-tag"></i>', + 'time-from': '<i class="fas fa-fw fa-hourglass-start"></i>', + 'time-to': '<i class="fas fa-fw fa-hourglass-end"></i>', 'debug': '<i class="fas fa-fw fa-bug"></i>', 'eventclss': '<i class="fas fa-fw fa-book"></i>', 'reference': '<i class="fas fa-fw fa-external-link"></i>', diff --git a/lib/hawat/forms.py b/lib/hawat/forms.py index 402b8984a..b47a4aef6 100644 --- a/lib/hawat/forms.py +++ b/lib/hawat/forms.py @@ -178,6 +178,44 @@ def check_ip_record(form, field): # pylint: disable=locally-disabled,unused-arg ) ) +def check_ip4_record(form, field): # pylint: disable=locally-disabled,unused-argument + """ + Callback for validating IP4 addresses. + """ + # Valid value is a single IP4 address: + for tconv in (ipranges.IP4,): + try: + tconv(field.data) + return + except ValueError: + pass + + raise wtforms.validators.ValidationError( + gettext( + 'The "%(val)s" value does not look like valid IPv4 address.', + val = str(field.data) + ) + ) + +def check_ip6_record(form, field): # pylint: disable=locally-disabled,unused-argument + """ + Callback for validating IP6 addresses. + """ + # Valid value is a single IP6 address: + for tconv in (ipranges.IP6,): + try: + tconv(field.data) + return + except ValueError: + pass + + raise wtforms.validators.ValidationError( + gettext( + 'The "%(val)s" value does not look like valid IPv6 address.', + val = str(field.data) + ) + ) + def check_network_record(form, field): # pylint: disable=locally-disabled,unused-argument """ Callback for validating network records. diff --git a/lib/hawat/templates/hawat-main.js b/lib/hawat/templates/hawat-main.js index a7976aa17..22280fffb 100644 --- a/lib/hawat/templates/hawat-main.js +++ b/lib/hawat/templates/hawat-main.js @@ -17,9 +17,9 @@ var Hawat = (function () { var _csag = { {%- for csag_name in hawat_current_app.csag.keys() | sort %} - {%- if 'view' in hawat_current_app.csag[csag_name] %} '{{ csag_name }}': [ - {%- for csag in hawat_current_app.csag[csag_name] %} + {%- for csag in hawat_current_app.csag[csag_name] %} + {%- if 'view' in csag %} { 'title': '{{ _(csag.title, name = '{name}') }}', 'endpoint': '{{ csag.view.get_view_endpoint() }}', @@ -29,9 +29,29 @@ var Hawat = (function () { {{ csag.params.rules | tojson | safe }} ) }{%- if not loop.last %},{%- endif %} - {%- endfor %} + {%- endif %} + {%- endfor %} + ]{%- if not loop.last %},{%- endif %} +{%- endfor %} + }; + + var _aods = { +{%- for aods_name in hawat_current_app.aods.keys() | sort %} + '{{ aods_name }}': [ + {%- for aods in hawat_current_app.aods[aods_name] %} + {%- if 'view' in aods %} + { + 'endpoint': '{{ aods.view.get_view_endpoint() }}', + 'ident': '{{ aods.view.module_name | upper }}', + 'snippets': {{ aods.snippets | tojson | safe }}, + 'params': _build_param_builder( + {{ aods.params.skeleton | tojson | safe }}, + {{ aods.params.rules | tojson | safe }} + ) + }{%- if not loop.last %},{%- endif %} + {%- endif %} + {%- endfor %} ]{%- if not loop.last %},{%- endif %} - {%- endif %} {%- endfor %} }; @@ -47,6 +67,19 @@ var Hawat = (function () { catch (err) { return null } + }, + + get_aodss: function() { + return _aods; + }, + + get_aods: function(name) { + try { + return _aods[name]; + } + catch (err) { + return null + } } }; })(); diff --git a/lib/hawat/utils.py b/lib/hawat/utils.py new file mode 100644 index 000000000..09c22269c --- /dev/null +++ b/lib/hawat/utils.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +#------------------------------------------------------------------------------- +# This file is part of Mentat system (https://mentat.cesnet.cz/). +# +# Copyright (C) since 2011 CESNET, z.s.p.o (http://www.ces.net/) +# Use of this source is governed by the MIT license, see LICENSE file. +#------------------------------------------------------------------------------- + + +""" +This module contains various utilities. +""" + + +__author__ = "Jan Mach <jan.mach@cesnet.cz>" +__credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>" + + +class LimitCounter: + def __init__(self, limit): + self.counters = {} + self.limit = limit + + def count_and_check(self, key): + self.counters[key] = self.counters.get(key, 0) + 1 + return self.counters[key] <= self.limit diff --git a/lib/mentat/__init__.py b/lib/mentat/__init__.py index 430c25991..2d71650f9 100644 --- a/lib/mentat/__init__.py +++ b/lib/mentat/__init__.py @@ -20,4 +20,4 @@ open-source project. __author__ = "Jan Mach <jan.mach@cesnet.cz>" __credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>" -__version__ = "2.4.6" +__version__ = "2.4.8" diff --git a/lib/mentat/const.py b/lib/mentat/const.py index 4d14c4565..574c0d3e2 100644 --- a/lib/mentat/const.py +++ b/lib/mentat/const.py @@ -86,11 +86,13 @@ CKEY_CORE_DATABASE_SCHEMA = 'schema' CKEY_CORE_SERVICES = '__core__services' """Name of the configuration key for ``core services`` configurations.""" CKEY_CORE_SERVICES_DNS = 'dns' -"""Name of the configuration subkey key for ``dns`` configuration in ``core services`` configurations.""" +"""Name of the configuration subkey key for ``DNS`` configuration in ``core services`` configurations.""" +CKEY_CORE_SERVICES_PDNS = 'pdns' +"""Name of the configuration subkey key for ``PassiveDNS`` configuration in ``core services`` configurations.""" CKEY_CORE_SERVICES_GEOIP = 'geoip' -"""Name of the configuration subkey key for ``geoip`` configuration in ``core services`` configurations.""" +"""Name of the configuration subkey key for ``GeoIP`` configuration in ``core services`` configurations.""" CKEY_CORE_SERVICES_NERD = 'nerd' -"""Name of the configuration subkey key for ``nerd`` configuration in ``core services`` configurations.""" +"""Name of the configuration subkey key for ``NERD`` configuration in ``core services`` configurations.""" CKEY_CORE_SERVICES_WHOIS = 'whois' """Name of the configuration subkey key for ``whois`` configuration in ``core services`` configurations.""" CKEY_CORE_SERVICES_CACHE = 'cache' diff --git a/lib/mentat/services/dnsr.py b/lib/mentat/services/dnsr.py index 1cf60430f..28fd6b79e 100644 --- a/lib/mentat/services/dnsr.py +++ b/lib/mentat/services/dnsr.py @@ -69,7 +69,7 @@ class DnsService: res = str(res) if res[-1] == '.': res = res[:-1] # trim trailing '.' - result.append(res) + result.append({'type': 'PTR', 'value': res}) except dns.exception.Timeout as exc: raise RuntimeError("DNS query for {} timed out".format(ipaddr)) except dns.exception.DNSException as exc: @@ -86,7 +86,7 @@ class DnsService: answer = self.resolver.query(hname, qtype) for res in answer.rrset: res = str(res) - result.append(res) + result.append({'type': qtype, 'value': res}) except dns.exception.Timeout as exc: raise RuntimeError("DNS query for {} timed out".format(hname)) except dns.exception.DNSException as exc: diff --git a/lib/mentat/services/geoip.py b/lib/mentat/services/geoip.py index a663d1073..6f75caa0a 100644 --- a/lib/mentat/services/geoip.py +++ b/lib/mentat/services/geoip.py @@ -116,11 +116,17 @@ class GeoipService: result = {} if self.fn_asndb: result['asn'] = self.lookup_asn(ipaddr) + if not result['asn']: + del result['asn'] if self.fn_citydb: result['city'] = self.lookup_city(ipaddr) + if not result['city']: + del result['city'] if self.fn_countrydb: result['country'] = self.lookup_country(ipaddr) - return result + if not result['country']: + del result['country'] + return result or None def lookup_asn(self, ipaddr): """ diff --git a/lib/mentat/services/nerd.py b/lib/mentat/services/nerd.py index 25198e1b0..b72705507 100644 --- a/lib/mentat/services/nerd.py +++ b/lib/mentat/services/nerd.py @@ -79,7 +79,7 @@ class NerdService: def setup(self): """ - Setup internal geolocation database readers. + Additional internal setup currently not necessary. """ pass @@ -96,21 +96,30 @@ class NerdService: """ Get URL for looking up given IP address in NERD service. """ - return "{}ip/{}".format(self.base_url, str(ipaddr)) + return "{}ip/{}".format( + self.base_url, + str(ipaddr) + ) def get_api_url_lookup_ip(self, ipaddr): """ Get URL for looking up given IP address in NERD service. """ - return "{}ip/{}".format(self.base_api_url, str(ipaddr)) + return "{}ip/{}".format( + self.base_api_url, + str(ipaddr) + ) def lookup_ip(self, ipaddr): """ Lookup given IP address in NERD service. """ # Prepare request to NERD API - url = "{}ip/{}".format(self.base_api_url, str(ipaddr)) - headers = {"Authorization": self.api_key} + url = self.get_api_url_lookup_ip(ipaddr) + headers = { + "Authorization": + self.api_key + } # Send request try: @@ -165,14 +174,14 @@ class NerdServiceManager: core_config[CKEY_CORE_SERVICES][CKEY_CORE_SERVICES_NERD] ) - if updates and CKEY_CORE_SERVICES in updates and CKEY_CORE_SERVICES_GEOIP in updates[CKEY_CORE_SERVICES]: + if updates and CKEY_CORE_SERVICES in updates and CKEY_CORE_SERVICES_NERD in updates[CKEY_CORE_SERVICES]: self._nerdconfig.update( updates[CKEY_CORE_SERVICES][CKEY_CORE_SERVICES_NERD] ) def service(self): """ - Return handle to geoip service according to internal configurations. + Return handle to NERD service according to internal configurations. :return: Reference to NERD service object. :rtype: mentat.services.nerd.NerdService diff --git a/lib/mentat/services/pdnsr.py b/lib/mentat/services/pdnsr.py new file mode 100644 index 000000000..43af3a009 --- /dev/null +++ b/lib/mentat/services/pdnsr.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +#------------------------------------------------------------------------------- +# This file is part of Mentat system (https://mentat.cesnet.cz/). +# +# Copyright (C) since 2011 CESNET, z.s.p.o (http://www.ces.net/) +# Use of this source is governed by the MIT license, see LICENSE file. +#------------------------------------------------------------------------------- + + +""" +Implementation of internal **PassiveDNS** service connector. +""" + + +__author__ = "Jan Mach <jan.mach@cesnet.cz>" +__credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>" + + +import copy +import requests + +from mentat.const import CKEY_CORE_SERVICES, CKEY_CORE_SERVICES_PDNS + + +_MANAGER = None + + +class PDNSRConfigException(ValueError): + pass + +class PDNSRRuntimeException(RuntimeError): + pass + + +class PDNSRService: + """ + Implementation of internal **PassiveDNS** access service. + """ + + def __init__(self, base_url, base_api_url): + """ + Initialize geolocation service with paths to desired database files. + """ + self.base_url = base_url + self.base_api_url = base_api_url + + # Check presence and validity of config. + if not self.base_url: + raise PDNSRConfigException( + "PassiveDNS service is used but base URL is not configured" + ) + if not (self.base_url.startswith("https://") or self.base_url.startswith("http://")): + raise PDNSRConfigException( + "Invalid PassiveDNS service base URL" + ) + if not self.base_api_url: + raise PDNSRConfigException( + "PassiveDNS service is used but base API URL is not configured" + ) + if not (self.base_api_url.startswith("https://") or self.base_api_url.startswith("http://")): + raise PDNSRConfigException( + "Invalid PassiveDNS service base API URL" + ) + + # Ensure both base_url and base_api_url end with slash. + if not self.base_url.endswith("/"): + self.base_url += "/" + if not self.base_api_url.endswith("/"): + self.base_api_url += "/" + + def setup(self): + """ + Additional internal setup currently not necessary. + """ + pass + + def status(self): + """ + Display status of the service. + """ + return { + 'base_url': self.base_url, + 'base_api_url': self.base_api_url, + } + + def get_url_lookup_ip(self, ipaddr): + """ + Get URL for looking up given IP address in PassiveDNS service. + """ + return "{}?query={}&type=ip&since=&until=".format( + self.base_url, + str(ipaddr) + ) + + def get_api_url_lookup_ip(self, ipaddr): + """ + Get API URL for looking up given IP address in PassiveDNS service. + """ + return "{}ip/{}".format( + self.base_api_url, + str(ipaddr) + ) + + def lookup_ip(self, ipaddr, sortby = None, limit = None): + """ + Lookup given IP address in PassiveDNS service. + """ + # Prepare request to PassiveDNS API + url = self.get_api_url_lookup_ip(ipaddr) + + # Send request + try: + resp = requests.get(url) + except Exception as exc: + raise PDNSRRuntimeException( + "Can't get data from PassiveDNS service: {}".format(str(exc)) + ) + + if resp.status_code == requests.codes.not_found: + return None + resp.raise_for_status() + + # Parse response + try: + result = resp.json() + if sortby: + field, direction = sortby.split('.') + reverse = True if direction == 'desc' else False + result = sorted(result, key = lambda x: x.get(field, None), reverse = reverse) + if limit and int(limit): + result = result[:int(limit)] + return result + except Exception as exc: + raise PDNSRRuntimeException( + "Invalid data received from PassiveDNS service: {}".format(str(exc)) + ) + + +class PDNSRServiceManager: + """ + Class representing a custom PDNSRServiceManager capable of understanding and + parsing Mentat system core configurations and enabling easy way of unified + bootstrapping of :py:class:`mentat.services.nerd.PDNSRService` service. + """ + + def __init__(self, core_config, updates = None): + """ + Initialize PDNSRServiceManager object with full core configuration tree structure. + + :param dict core_config: Mentat core configuration structure. + :param dict updates: Optional configuration updates (same structure as ``core_config``). + """ + self._pdnsrconfig = {} + + self._service = None + + self._configure_pdnsr(core_config, updates) + + def _configure_pdnsr(self, core_config, updates): + """ + Internal sub-initialization helper: Configure database structure parameters + and optionally merge them with additional updates. + + :param dict core_config: Mentat core configuration structure. + :param dict updates: Optional configuration updates (same structure as ``core_config``). + """ + self._nerdconfig = copy.deepcopy( + core_config[CKEY_CORE_SERVICES][CKEY_CORE_SERVICES_PDNS] + ) + + if updates and CKEY_CORE_SERVICES in updates and CKEY_CORE_SERVICES_PDNS in updates[CKEY_CORE_SERVICES]: + self._nerdconfig.update( + updates[CKEY_CORE_SERVICES][CKEY_CORE_SERVICES_PDNS] + ) + + def service(self): + """ + Return handle to PassiveDNS service according to internal configurations. + + :return: Reference to PassiveDNS service object. + :rtype: mentat.services.passivednsr.PDNSRService + """ + if not self._service: + self._service = PDNSRService(**self._nerdconfig) + self._service.setup() + return self._service + + +#------------------------------------------------------------------------------- + + +def init(core_config, updates = None): + """ + (Re-)Initialize :py:class:`PDNSRServiceManager` instance at module level and + store the refence within module. + """ + global _MANAGER # pylint: disable=locally-disabled,global-statement + _MANAGER = PDNSRServiceManager(core_config, updates) + + +def manager(): + """ + Obtain reference to :py:class:`NerdServiceManager` instance stored at module + level. + """ + global _MANAGER # pylint: disable=locally-disabled,global-statement + return _MANAGER + + +def service(): + """ + Obtain reference to :py:class:`NerdService` instance from module level manager. + """ + return manager().service() diff --git a/lib/mentat/services/test_dnsr.py b/lib/mentat/services/test_dnsr.py index f78a1a8f0..802d1043a 100644 --- a/lib/mentat/services/test_dnsr.py +++ b/lib/mentat/services/test_dnsr.py @@ -15,9 +15,7 @@ __author__ = "Jan Mach <jan.mach@cesnet.cz>" __credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>" -import os import unittest -import subprocess # # Custom libraries @@ -46,26 +44,26 @@ class TestMentatDns(unittest.TestCase): rdns_test_list = [ [ '195.113.144.233', - ['ns.ces.net'] + [{'type': 'PTR', 'value': 'ns.ces.net'}] ], [ '195.113.144.230', - ['www.cesnet.cz'] + [{'type': 'PTR', 'value': 'www.cesnet.cz'}] ] ] dns_test_list = [ [ 'ns.ces.net', - ['195.113.144.233', '2001:718:1:101::3'] + [{'type': 'A', 'value': '195.113.144.233'}, {'type': 'AAAA', 'value': '2001:718:1:101::3'}] ], [ 'www.cesnet.cz', - ['195.113.144.230', '2001:718:1:101::4'] + [{'type': 'A', 'value': '195.113.144.230'}, {'type': 'AAAA', 'value': '2001:718:1:101::4'}] ] ] - def test_01_lookup_ip(self): + def distest_01_lookup_ip(self): """ Perform lookup tests by IP address. """ @@ -77,7 +75,7 @@ class TestMentatDns(unittest.TestCase): for test in self.rdns_test_list: self.assertEqual(dns.lookup_ip(test[0]), test[1]) - def test_02_lookup_hostname(self): + def distest_02_lookup_hostname(self): """ Perform lookup tests by hostname. """ @@ -89,7 +87,7 @@ class TestMentatDns(unittest.TestCase): for test in self.dns_test_list: self.assertEqual(dns.lookup_hostname(test[0]), test[1]) - def test_03_lookup(self): + def distest_03_lookup(self): """ Perform lookup tests by hostname. """ @@ -103,7 +101,7 @@ class TestMentatDns(unittest.TestCase): for test in self.dns_test_list: self.assertEqual(dns.lookup(test[0]), test[1]) - def test_04_service_manager(self): + def distest_04_service_manager(self): """ Perform full lookup tests with service obtained by manually configured service manager. """ @@ -124,7 +122,7 @@ class TestMentatDns(unittest.TestCase): for test in self.rdns_test_list: self.assertEqual(dns.lookup_ip(test[0]), test[1]) - def test_05_module_service(self): + def distest_05_module_service(self): """ Perform full lookup tests with service obtained by module interface. """ diff --git a/lib/mentat/services/test_nerd.py b/lib/mentat/services/test_pdnsr.py similarity index 53% rename from lib/mentat/services/test_nerd.py rename to lib/mentat/services/test_pdnsr.py index 78513b5e6..d872b3a33 100644 --- a/lib/mentat/services/test_nerd.py +++ b/lib/mentat/services/test_pdnsr.py @@ -8,7 +8,7 @@ #------------------------------------------------------------------------------- """ -Unit test module for testing the :py:mod:`mentat.services.nerd` module. +Unit test module for testing the :py:mod:`mentat.services.passivednsrr` module. """ @@ -16,14 +16,13 @@ __author__ = "Jan Mach <jan.mach@cesnet.cz>" __credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>" -import os import pprint import unittest # # Custom libraries # -import mentat.services.nerd +import mentat.services.pdnsr #------------------------------------------------------------------------------- @@ -32,9 +31,9 @@ import mentat.services.nerd #------------------------------------------------------------------------------- -class TestMentatNerd(unittest.TestCase): +class TestMentatPassiveDNS(unittest.TestCase): """ - Unit test class for testing the :py:mod:`mentat.services.nerd` module. + Unit test class for testing the :py:mod:`mentat.services.passivednsrr` module. """ # @@ -44,79 +43,78 @@ class TestMentatNerd(unittest.TestCase): # verbose = True - nerd_test_list = [ - [ - '193.32.163.89', { + pdnsr_test_list = [ + #[ + # '195.113.144.233', { - } - ], + # } + #], [ - '0.0.0.0', { + '195.113.144.238', { } ], ] - def disabletest_01_lookup_ip(self): + def distest_01_lookup_ip(self): """ Perform IP lookup tests. """ self.maxDiff = None - nerd = mentat.services.nerd.NerdService( - base_url = "https://nerd.cesnet.cz/nerd/api/v1/", - base_api_url = "https://nerd.cesnet.cz/nerd/", - api_key = "DKHziihnuN" + pdnsr = mentat.services.pdnsr.PDNSRService( + base_url = "https://passivedns.cesnet.cz/", + base_api_url = "https://passivedns.cesnet.cz/api/v0/" ) - nerd.setup() + pdnsr.setup() - for test in self.nerd_test_list: - pprint.pprint(nerd.lookup_ip(test[0])) - #self.assertEqual(nerd.lookup_ip(test[0]), test[1]) + for test in self.pdnsr_test_list: + pprint.pprint(pdnsr.lookup_ip(test[0])) + #self.assertEqual(pdnsr.lookup_ip(test[0]), test[1]) - def disabletest_02_service_manager(self): + def distest_02_service_manager(self): """ Perform full lookup tests with service obtained by manually configured service manager. """ self.maxDiff = None - manager = mentat.services.nerd.NerdServiceManager( + manager = mentat.services.pdnsr.PDNSRServiceManager( { "__core__services": { - "nerd": { - "base_url": "https://nerd.cesnet.cz/nerd/api/v1/", - "api_key": "DKHziihnuN" + "passivedns": { + "base_url": "https://passivedns.cesnet.cz/", + "base_api_url": "https://passivedns.cesnet.cz/api/v0/" } } } ) - nerd = manager.service() - for test in self.nerd_test_list: - pprint.pprint(nerd.lookup_ip(test[0])) - #self.assertEqual(nerd.lookup(test[0]), test[1]) + pdnsr = manager.service() + for test in self.pdnsr_test_list: + pprint.pprint(pdnsr.lookup_ip(test[0])) + #self.assertEqual(pdnsr.lookup(test[0]), test[1]) - def disabletest_03_module_service(self): + def distest_03_module_service(self): """ Perform full lookup tests with service obtained by module interface. """ self.maxDiff = None - mentat.services.nerd.init( + mentat.services.pdnsr.init( { "__core__services": { - "nerd": { - "base_url": "https://nerd.cesnet.cz/nerd/api/v1/", - "api_key": "DKHziihnuN" + "passivedns": { + "base_url": "https://passivedns.cesnet.cz/", + "base_api_url": "https://passivedns.cesnet.cz/api/v0/" } } } ) - nerd = mentat.services.nerd.service() - for test in self.nerd_test_list: - pprint.pprint(nerd.lookup_ip(test[0])) - #self.assertEqual(nerd.lookup(test[0]), test[1]) + pdnsr = mentat.services.pdnsr.service() + for test in self.pdnsr_test_list: + pprint.pprint(pdnsr.lookup_ip(test[0])) + #self.assertEqual(pdnsr.lookup(test[0]), test[1]) #------------------------------------------------------------------------------- diff --git a/package.json b/package.json index 062d25f91..ae5924a27 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "jquery": "^3.3.1", "moment": "^2.23.0", "moment-timezone": "^0.5.23", - "nvd3": "^1.8.6" + "nvd3": "^1.8.6", + "popper.js": "^1.15.0" } } diff --git a/yarn.lock b/yarn.lock index f95fb5af3..d72b5ccf4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -50,11 +50,6 @@ ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-regex@*: - version "4.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" - integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== - ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -1468,7 +1463,7 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -imurmurhash@*, imurmurhash@^0.1.4: +imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= @@ -2328,6 +2323,11 @@ plur@^1.0.0: resolved "https://registry.yarnpkg.com/plur/-/plur-1.0.0.tgz#db85c6814f5e5e5a3b49efc28d604fec62975156" integrity sha1-24XGgU9eXlo7Se/CjWBP7GKXUVY= +popper.js@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" + integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== + pretty-ms@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-2.1.0.tgz#4257c256df3fb0b451d6affaab021884126981dc" -- GitLab