From 5279f0b01963365931323109818854bae1f957d9 Mon Sep 17 00:00:00 2001 From: ziirish Date: Mon, 29 Jan 2018 22:35:45 +0100 Subject: [PATCH] add: client configuration templates (fix #155) --- CHANGELOG.rst | 1 + burpui/api/settings.py | 198 +++++++++++++++++++++++++------ burpui/misc/backend/burp1.py | 4 +- burpui/misc/backend/interface.py | 2 +- burpui/misc/backend/multi.py | 4 +- burpui/misc/parser/burp1.py | 107 +++++++++++++---- burpui/misc/parser/interface.py | 33 +++++- burpui/misc/parser/utils.py | 127 ++++++++++++++++++-- burpui/routes.py | 2 + burpui/templates/js/settings.js | 175 ++++++++++++++++++++------- burpui/templates/layout.html | 1 + burpui/templates/settings.html | 107 ++++++++++++++++- burpui/templates/sideconfig.html | 16 +-- docs/upgrading.rst | 2 + setup.py | 1 + tests/test_burpui.py | 3 +- 16 files changed, 642 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5193214f..19aa0671 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ Current - **BREAKING**: the *running* backups are now displayed in ``green`` instead of ``blue`` - Add: new plugins system to allow users to write their own modules - Add: `Italian translation `_ thanks to Enrico +- Add: new `client configuration templates `_ - Add: `backups deletion `_ - Add: `show last client status in client view `_ - Add: `record login failure attempt `_ diff --git a/burpui/api/settings.py b/burpui/api/settings.py index 35dc20c2..c8a2925f 100644 --- a/burpui/api/settings.py +++ b/burpui/api/settings.py @@ -17,7 +17,7 @@ from ..utils import NOTIF_INFO from six import iteritems from flask_babel import gettext as _, refresh -from flask import jsonify, request, url_for, current_app +from flask import jsonify, request, url_for, current_app, g from ..datastructures import ImmutableMultiDict bui = current_app # type: BUIServer @@ -236,21 +236,40 @@ class ServerSettings(Resource): res = bui.client.read_conf_srv(conf, server) refresh() # Translate the doc and placeholder API side - doc = bui.client.get_parser_attr('doc', server).copy() - placeholders = bui.client.get_parser_attr('placeholders', server).copy() - for key, val in iteritems(doc): - doc[key] = _(val) - for key, val in iteritems(placeholders): - placeholders[key] = _(val) + cache_keys = { + 'doc': '_doc_parser_{}-{}'.format(server, g.locale), + 'placeholders': '_placeholders_parser_{}-{}'.format(server, g.locale), + 'boolean_srv': '_boolean_srv_parser_{}'.format(server), + 'string_srv': '_string_srv_parser_{}'.format(server), + 'integer_srv': '_integer_srv_parser_{}'.format(server), + 'multi_srv': '_multi_srv_parser_{}'.format(server), + 'values': '_suggest_parser_{}'.format(server), + 'defaults': '_defaults_parser_{}'.format(server), + } + cache_results = {} + for name, key in iteritems(cache_keys): + if not cache.cache.has(key): + if name in ['doc', 'placeholders']: + _tmp = bui.client.get_parser_attr(name, server).copy() + _tmp2 = {} + for k, v in iteritems(_tmp): + _tmp2[k] = _(v) + cache_results[name] = _tmp2 + else: + cache_results[name] = bui.client.get_parser_attr(name, server) + cache.cache.set(key, cache_results[name], 3600) + else: + cache_results[name] = cache.cache.get(key) + return jsonify(results=res, - boolean=bui.client.get_parser_attr('boolean_srv', server), - string=bui.client.get_parser_attr('string_srv', server), - integer=bui.client.get_parser_attr('integer_srv', server), - multi=bui.client.get_parser_attr('multi_srv', server), - server_doc=doc, - suggest=bui.client.get_parser_attr('values', server), - placeholders=placeholders, - defaults=bui.client.get_parser_attr('defaults', server)) + boolean=cache_results['boolean_srv'], + string=cache_results['string_srv'], + integer=cache_results['integer_srv'], + multi=cache_results['multi_srv'], + server_doc=cache_results['doc'], + suggest=cache_results['values'], + placeholders=cache_results['placeholders'], + defaults=cache_results['defaults']) @ns.route('/clients', @@ -273,10 +292,84 @@ class ClientsList(Resource): ) def get(self, server=None): """Returns a list of clients""" - res = bui.client.clients_list(server) + parser = bui.client.get_parser(agent=server) + res = parser.list_clients() return jsonify(result=res) +@ns.route('/templates', + '//templates', + endpoint='templates_list') +@ns.doc( + params={ + 'server': 'Which server to collect data from when in multi-agent mode', + }, +) +class TemplatesList(Resource): + + @api.acl_admin_required(message='Sorry, you don\'t have rights to access the setting panel') + @ns.doc( + responses={ + 200: 'Success', + 403: 'Insufficient permissions', + 500: 'Internal failure', + } + ) + def get(self, server=None): + """Returns a list of clients""" + parser = bui.client.get_parser(agent=server) + res = parser.list_templates() + return jsonify(result=res) + + +@ns.route('/template', + '//template', + endpoint='new_template', + methods=['PUT']) +@ns.doc( + params={ + 'server': 'Which server to collect data from when in multi-agent mode', + }, +) +class NewTemplateSettings(Resource): + parser = ns.parser() + parser.add_argument('newtemplate', required=True, help="No 'newclient' provided") + + @api.disabled_on_demo() + @api.acl_admin_required(message='Sorry, you don\'t have rights to access the setting panel') + @ns.expect(parser) + @ns.doc( + responses={ + 200: 'Success', + 400: 'Missing parameter', + 403: 'Insufficient permissions', + 500: 'Internal failure', + } + ) + def put(self, server=None): + """Creates a new client""" + newtemplate = self.parser.parse_args()['newtemplate'] + if not newtemplate: + self.abort(400, 'No template name provided') + parser = bui.client.get_parser(agent=server) + templates = parser.list_templates() + for tpl in templates: + if tpl['name'] == newtemplate: + self.abort(409, "Template '{}' already exists".format(newtemplate)) + # clientconfdir = bui.client.get_parser_attr('clientconfdir', server) + # if not clientconfdir: + # flash('Could not proceed, no \'clientconfdir\' find', 'warning') + # return redirect(request.referrer) + noti = bui.client.store_conf_cli(ImmutableMultiDict(), newtemplate, None, True, server) + if server: + noti.append([NOTIF_INFO, _('Click here to edit \'%(template)s\' configuration', url=url_for('view.cli_settings', server=server, client=newtemplate, template=True), template=newtemplate)]) + else: + noti.append([NOTIF_INFO, _('Click here to edit \'%(template)s\' configuration', url=url_for('view.cli_settings', client=newtemplate, template=True), template=newtemplate)]) + # clear the cache when we add a new client + cache.clear() + return {'notif': noti}, 201 + + @ns.route('/config', '//config', endpoint='new_client', @@ -306,7 +399,8 @@ class NewClientSettings(Resource): newclient = self.parser.parse_args()['newclient'] if not newclient: self.abort(400, 'No client name provided') - clients = bui.client.clients_list(server) + parser = bui.client.get_parser(agent=server) + clients = parser.list_clients() for cl in clients: if cl['name'] == newclient: self.abort(409, "Client '{}' already exists".format(newclient)) @@ -345,9 +439,15 @@ class ClientSettings(Resource): parser_delete.add_argument('revoke', type=boolean, help='Whether to revoke the certificate or not', default=False, nullable=True) parser_delete.add_argument('delcert', type=boolean, help='Whether to delete the certificate or not', default=False, nullable=True) parser_delete.add_argument('keepconf', type=boolean, help='Whether to keep the conf or not', default=False, nullable=True) + parser_delete.add_argument('template', type=boolean, help='Whether we work on a template or not', default=False, nullable=True) + parser_post = ns.parser() + parser_post.add_argument('template', type=boolean, help='Whether we work on a template or not', default=False, nullable=True) + parser_get = ns.parser() + parser_get.add_argument('template', type=boolean, help='Whether we work on a template or not', default=False, nullable=True) @api.disabled_on_demo() @api.acl_admin_required(message=_('Sorry, you don\'t have rights to access the setting panel')) + @ns.expect(parser_post) @ns.doc( responses={ 200: 'Success', @@ -357,10 +457,13 @@ class ClientSettings(Resource): ) def post(self, server=None, client=None, conf=None): """Saves a given client configuration""" - noti = bui.client.store_conf_cli(request.form, client, conf, server) + args = self.parser_post.parse_args() + template = args.get('template', False) + noti = bui.client.store_conf_cli(request.form, client, conf, template, server) return {'notif': noti} @api.acl_admin_required(message=_('Sorry, you don\'t have rights to access the setting panel')) + @ns.expect(parser_get) @ns.doc( responses={ 200: 'Success', @@ -374,25 +477,47 @@ class ClientSettings(Resource): conf = unquote(conf) except: pass - res = bui.client.read_conf_cli(client, conf, server) + args = self.parser_get.parse_args() + template = args.get('template', False) + parser = bui.client.get_parser() + res = parser.read_client_conf(client, conf, template) refresh() # Translate the doc and placeholder API side - doc = bui.client.get_parser_attr('doc', server).copy() - placeholders = bui.client.get_parser_attr('placeholders', server).copy() - for key, val in iteritems(doc): - doc[key] = _(val) - for key, val in iteritems(placeholders): - placeholders[key] = _(val) + cache_keys = { + 'doc': '_doc_parser_{}-{}'.format(server, g.locale), + 'placeholders': '_placeholders_parser_{}-{}'.format(server, g.locale), + 'boolean_cli': '_boolean_cli_parser_{}'.format(server), + 'string_cli': '_string_cli_parser_{}'.format(server), + 'integer_cli': '_integer_cli_parser_{}'.format(server), + 'multi_cli': '_multi_cli_parser_{}'.format(server), + 'values': '_suggest_parser_{}'.format(server), + 'defaults': '_defaults_parser_{}'.format(server), + } + cache_results = {} + for name, key in iteritems(cache_keys): + if not cache.cache.has(key): + if name in ['doc', 'placeholders']: + _tmp = bui.client.get_parser_attr(name, server).copy() + _tmp2 = {} + for k, v in iteritems(_tmp): + _tmp2[k] = _(v) + cache_results[name] = _tmp2 + else: + cache_results[name] = bui.client.get_parser_attr(name, server) + cache.cache.set(key, cache_results[name], 3600) + else: + cache_results[name] = cache.cache.get(key) + return jsonify( results=res, - boolean=bui.client.get_parser_attr('boolean_cli', server), - string=bui.client.get_parser_attr('string_cli', server), - integer=bui.client.get_parser_attr('integer_cli', server), - multi=bui.client.get_parser_attr('multi_cli', server), - server_doc=doc, - suggest=bui.client.get_parser_attr('values', server), - placeholders=placeholders, - defaults=bui.client.get_parser_attr('defaults', server) + boolean=cache_results['boolean_cli'], + string=cache_results['string_cli'], + integer=cache_results['integer_cli'], + multi=cache_results['multi_cli'], + server_doc=cache_results['doc'], + suggest=cache_results['values'], + placeholders=cache_results['placeholders'], + defaults=cache_results['defaults'] ) @api.disabled_on_demo() @@ -411,6 +536,7 @@ class ClientSettings(Resource): delcert = args.get('delcert', False) revoke = args.get('revoke', False) keepconf = args.get('keepconf', False) + template = args.get('template', False) if not keepconf: # clear the cache when we remove a client @@ -418,7 +544,8 @@ class ClientSettings(Resource): if bui.config['WITH_CELERY']: from ..tasks import force_scheduling_now force_scheduling_now() - return bui.client.delete_client(client, keepconf=keepconf, delcert=delcert, revoke=revoke, agent=server), 200 + parser = bui.client.get_parser() + return parser.remove_client(client, keepconf, delcert, revoke, template), 200 @ns.route('/path-expander', @@ -459,7 +586,8 @@ class PathExpander(Resource): path = unquote(path) if source: source = unquote(source) - paths = bui.client.expand_path(path, source, client, server) + parser = bui.client.get_parser(agent=server) + paths = parser.path_expander(path, source, client) if not paths: self.abort(403, 'Path not found') return {'result': paths} diff --git a/burpui/misc/backend/burp1.py b/burpui/misc/backend/burp1.py index 4e76b146..ba629ba8 100644 --- a/burpui/misc/backend/burp1.py +++ b/burpui/misc/backend/burp1.py @@ -1000,7 +1000,7 @@ class Burp(BUIbackend): return [] return self.parser.read_server_conf(conf) - def store_conf_cli(self, data, client=None, conf=None, agent=None): + def store_conf_cli(self, data, client=None, conf=None, template=False, agent=None): """See :func:`burpui.misc.backend.interface.BUIbackend.store_conf_cli`""" if not self.parser: return [] @@ -1008,7 +1008,7 @@ class Burp(BUIbackend): conf = unquote(conf) except: pass - return self.parser.store_client_conf(data, client, conf) + return self.parser.store_client_conf(data, client, conf, template) def store_conf_srv(self, data, conf=None, agent=None): """See :func:`burpui.misc.backend.interface.BUIbackend.store_conf_srv`""" diff --git a/burpui/misc/backend/interface.py b/burpui/misc/backend/interface.py index ae2b58ad..b3fe85fa 100644 --- a/burpui/misc/backend/interface.py +++ b/burpui/misc/backend/interface.py @@ -806,7 +806,7 @@ class BUIbackend(with_metaclass(ABCMeta, object)): raise NotImplementedError("Sorry, the current Backend does not implement this method!") # pragma: no cover @abstractmethod - def store_conf_cli(self, data, client=None, conf=None, agent=None): + def store_conf_cli(self, data, client=None, conf=None, template=False, agent=None): """The :func:`burpui.misc.backend.interface.BUIbackend.store_conf_cli` function works the same way as the :func:`burpui.misc.backend.interface.BUIbackend.store_conf_srv` function diff --git a/burpui/misc/backend/multi.py b/burpui/misc/backend/multi.py index f3c13cc0..d60205e3 100644 --- a/burpui/misc/backend/multi.py +++ b/burpui/misc/backend/multi.py @@ -511,7 +511,7 @@ class NClient(BUIbackend): """ @implement - def store_conf_cli(self, data, client=None, conf=None, agent=None): + def store_conf_cli(self, data, client=None, conf=None, template=False, agent=None): """See :func:`burpui.misc.backend.interface.BUIbackend.store_conf_cli`""" # serialize data as it is a nested dict import hmac @@ -527,7 +527,7 @@ class NClient(BUIbackend): data = ImmutableMultiDict(data.to_dict(False)) key = '{}{}'.format(self.password, 'store_conf_cli') key = to_bytes(key) - pickles = b64encode(pickle.dumps({'data': data, 'conf': conf, 'client': client}, 2)) + pickles = b64encode(pickle.dumps({'data': data, 'conf': conf, 'client': client, 'template': template}, 2)) bytes_pickles = to_bytes(pickles) digest = hmac.new(key, bytes_pickles, hashlib.sha1).hexdigest() data = {'func': 'store_conf_cli', 'args': pickles, 'pickled': True, 'digest': digest} diff --git a/burpui/misc/parser/burp1.py b/burpui/misc/parser/burp1.py index 066cda71..11a95fe3 100644 --- a/burpui/misc/parser/burp1.py +++ b/burpui/misc/parser/burp1.py @@ -41,8 +41,12 @@ class Parser(Doc): self._server_conf = {} self._client_conf = {} self._clients_conf = {} + self._templates_conf = {} self.clientconfdir = None self.clientconfdir_mtime = None + self.templates = [] + self.templates_dir = '.buitemplates' + self.templates_path = None self.filescache = {} self._configs = {} self.root = None @@ -89,6 +93,7 @@ class Parser(Doc): self._server_conf.clear() self._client_conf.clear() self._clients_conf.clear() + self._list_templates(True) self._list_clients(True) def _load_conf_srv(self): @@ -96,6 +101,7 @@ class Parser(Doc): self._server_conf = Config(self.conf, self, 'srv') self._server_conf.parse() self.clientconfdir = self._server_conf.get('clientconfdir') + self.templates_path = os.path.join(self.clientconfdir, self.templates_dir) def _load_conf_cli(self): """Load the client configuration file""" @@ -120,11 +126,26 @@ class Parser(Doc): conf.parse() self._clients_conf[cli['name']] = conf + + def _load_conf_templates(self): + """Load all templates configuration""" + templates = self._list_templates(True) + + for template in templates: + conf = self.server_conf.clone() + path = os.path.join(self.templates_path, template['name']) + if template['name'] not in self._templates_conf: + conf.add_file(path) + conf.set_default(path) + conf.parse() + self._templates_conf[template['name']] = conf + def _load_all_conf(self): """Load all configurations""" self._load_conf_srv() self._load_conf_cli() self._load_conf_clients() + self._load_conf_templates() def _new_client_conf(self, name, path): """Create new client conf""" @@ -153,6 +174,17 @@ class Parser(Doc): self._clients_conf[name].parse() return self._clients_conf[name] + def _get_template(self, name): + """Return template conf and refresh it if necessary""" + if self._clientconfdir_changed() and name not in self._templates_conf: + self._templates_conf.clear() + self._load_conf_templates() + if name not in self._templates_conf: + return None + if self._templates_conf[name].changed: + self._templates_conf[name].parse() + return self._templates_conf[name] + def _get_config(self, path, mode='cli'): """Return conf by it's path""" if path in self._configs: @@ -200,6 +232,27 @@ class Parser(Doc): self.clientconfdir_mtime = os.path.getmtime(self.clientconfdir) return res + def _list_templates(self, force=False): + if not self.clientconfdir: + return [] + + if self.templates and not force and not self._clientconfdir_changed(): + return self.templates + + res = [] + for tpl in os.listdir(self.templates_path): + full_file = os.path.join(self.templates_path, tpl) + if (os.path.isfile(full_file) and not tpl.startswith('.') and + not tpl.endswith('~')): + res.append({ + 'name': tpl, + 'value': os.path.join(self.templates_dir, tpl) + }) + + self.templates = res + self.clientconfdir_mtime = os.path.getmtime(self.clientconfdir) + return res + def _get_server_path(self, name=None, fil=None): """Returns the path of the 'server *fil*' file""" if not name: @@ -256,7 +309,7 @@ class Parser(Doc): return False return self.openssl_auth.check_client_revoked(client) - def remove_client(self, client=None, keepconf=False, delcert=False, revoke=False): + def remove_client(self, client=None, keepconf=False, delcert=False, revoke=False, template=False): """See :func:`burpui.misc.parser.interface.BUIparser.remove_client`""" res = [] revoked = False @@ -265,12 +318,15 @@ class Parser(Doc): return [[NOTIF_ERROR, "No client provided"]] try: if not keepconf: - path = os.path.join(self.clientconfdir, client) + if template: + path = os.path.join(self.templates_path, client) + else: + path = os.path.join(self.clientconfdir, client) os.unlink(path) res.append([NOTIF_OK, "'{}' successfully removed".format(client)]) removed = True - if client in self._clients_conf: + if client in self._clients_conf and not template: del self._clients_conf[client] self._refresh_cache() @@ -302,7 +358,7 @@ class Parser(Doc): return res - def read_client_conf(self, client=None, conf=None): + def read_client_conf(self, client=None, conf=None, template=False): """ See :func:`burpui.misc.parser.interface.BUIparser.read_client_conf` """ @@ -313,7 +369,7 @@ class Parser(Doc): u'multi': [], u'includes': [], u'includes_ext': [], - u'clients': self._list_clients(), + u'templates': [], u'hierarchy': [], } if not client and not conf: @@ -323,8 +379,12 @@ class Parser(Doc): if not mconf: if not self.clientconfdir: return res - mconf = os.path.join(self.clientconfdir, client) - config = self._get_client(client, mconf) + if template: + mconf = os.path.join(self.templates_path, client) + config = self._get_template(client) + else: + mconf = os.path.join(self.clientconfdir, client) + config = self._get_client(client, mconf) else: config = self._get_config(mconf) @@ -337,6 +397,7 @@ class Parser(Doc): res2[u'boolean'] = parsed.boolean res2[u'integer'] = parsed.integer res2[u'multi'] = parsed.multi + res2[u'templates'] = parsed.template res2[u'includes'] = [ x for x in parsed.flatten('include', False).keys() @@ -363,7 +424,6 @@ class Parser(Doc): u'multi': [], u'includes': [], u'includes_ext': [], - u'clients': self._list_clients(), u'hierarchy': [], } if not conf: @@ -378,10 +438,6 @@ class Parser(Doc): return self.filescache[mconf]['dict'] clientconfdir = parsed.get('clientconfdir') - if clientconfdir and clientconfdir.parse() != self.clientconfdir: - self.clientconfdir = clientconfdir.parse() - self.clientconfdir_mtime = -1 - res['clients'] = self._list_clients() res2 = {} res2[u'common'] = parsed.string @@ -405,34 +461,43 @@ class Parser(Doc): def list_clients(self): """See :func:`burpui.misc.parser.interface.BUIparser.list_clients`""" self.read_server_conf() - if not self.clientconfdir: - return [] - return self._list_clients() - def store_client_conf(self, data, client=None, conf=None): + def list_templates(self): + """See :func:`burpui.misc.parser.interface.BUIparser.list_templates`""" + self.read_server_conf() + return self._list_templates() + + def store_client_conf(self, data, client=None, conf=None, template=False): """ See :func:`burpui.misc.parser.interface.BUIparser.store_client_conf` """ if conf and not os.path.isabs(conf): conf = os.path.join(self.clientconfdir, conf) if not conf and not client: + if template: + return [[NOTIF_ERROR, 'Sorry, no template defined']] return [[NOTIF_ERROR, 'Sorry, no client defined']] elif client and not conf: - conf = os.path.join(self.clientconfdir, client) + if template: + if not self.templates_path: + return [[NOTIF_ERROR, 'Sorry, no template directory found']] + conf = os.path.join(self.templates_path, client) + else: + conf = os.path.join(self.clientconfdir, client) ret = self.store_conf(data, conf, client, mode='cli') self._refresh_cache() # refresh client list return ret def store_conf(self, data, conf=None, client=None, mode='srv', - insecure=False): + insecure=False, template=False): """See :func:`burpui.misc.parser.interface.BUIparser.store_conf`""" mconf = None if not conf: mconf = self.conf else: mconf = conf - if mconf != self.conf and not mconf.startswith('/'): + if mconf != self.conf and not os.path.isabs(mconf): mconf = os.path.join(self.root, mconf) if not mconf: return [[NOTIF_WARN, 'Sorry, no configuration file defined']] @@ -447,7 +512,9 @@ class Parser(Doc): ] check = False - if client: + if template: + conffile = self._get_template(client, mconf).get_file(mconf) + elif client: conffile = self._get_client(client, mconf).get_file(mconf) else: conffile = self.server_conf.get_file(mconf) diff --git a/burpui/misc/parser/interface.py b/burpui/misc/parser/interface.py index 0c3446ca..9b79d87c 100644 --- a/burpui/misc/parser/interface.py +++ b/burpui/misc/parser/interface.py @@ -7,7 +7,7 @@ .. moduleauthor:: Ziirish """ -from abc import ABCMeta, abstractmethod +from abc import ABCMeta, abstractmethod, abstractproperty from six import with_metaclass import logging @@ -134,7 +134,7 @@ class BUIparser(with_metaclass(ABCMeta, object)): ) # pragma: no cover @abstractmethod - def store_client_conf(self, data, client=None, conf=None): + def store_client_conf(self, data, client=None, conf=None, template=False): """:func:`burpui.misc.parser.interface.BUIparser.store_client_conf` is used by :func:`burpui.misc.backend.BUIbackend.store_conf_cli`. @@ -144,6 +144,12 @@ class BUIparser(with_metaclass(ABCMeta, object)): :param client: Name of the client for which to apply this config :type client: str + + :param conf: The explicit filename of the conf + :type conf: str + + :param template: Is this file a template + :type template: bool """ raise NotImplementedError( "Sorry, the current Parser does not implement this method!" @@ -151,7 +157,7 @@ class BUIparser(with_metaclass(ABCMeta, object)): @abstractmethod def store_conf(self, data, conf=None, client=None, mode='srv', - insecure=False): + insecure=False, template=False): """:func:`burpui.misc.parser.interface.BUIparser.store_conf` is used to store the configuration from the web-ui into the actual configuration files. @@ -173,6 +179,9 @@ class BUIparser(with_metaclass(ABCMeta, object)): :param insecure: Used for the CLI :type insecure: bool + :param template: Is it a template + :type template: bool + :returns: A list of notifications to return to the UI (success or failure) @@ -216,6 +225,17 @@ class BUIparser(with_metaclass(ABCMeta, object)): "Sorry, the current Parser does not implement this method!" ) # pragma: no cover + @abstractmethod + def list_templates(self): + """:func:`burpui.misc.parser.interface.BUIparser.list_templates` is used + to retrieve a list of templates with their absolute paths. + + :returns: A list of templates + """ + raise NotImplementedError( + "Sorry, the current Parser does not implement this method!" + ) # pragma: no cover + @abstractmethod def is_client_revoked(self, client=None): """:func:`burpui.misc.parser.interface.BUIparser.is_client_revoked` is @@ -231,7 +251,7 @@ class BUIparser(with_metaclass(ABCMeta, object)): ) # pragma: no cover @abstractmethod - def remove_client(self, client=None, keepconf=False, delcert=False, revoke=False): + def remove_client(self, client=None, keepconf=False, delcert=False, revoke=False, template=False): """:func:`burpui.misc.parser.interface.BUIparser.remove_client` is used to delete a client from burp's configuration. @@ -247,6 +267,9 @@ class BUIparser(with_metaclass(ABCMeta, object)): :param revoke: Whether to revoke the associated certificate :type revoke: bool + :param template: Whether we remove a template + :type template: bool + :returns: A list of notifications to return to the UI (success or failure) """ @@ -255,7 +278,7 @@ class BUIparser(with_metaclass(ABCMeta, object)): ) # pragma: no cover @abstractmethod - def read_client_conf(self, client=None, conf=None): + def read_client_conf(self, client=None, conf=None, template=False): """:func:`burpui.misc.parser.interface.BUIparser.read_client_conf` is called by :func:`burpui.misc.backend.interface.BUIbackend.read_conf_cli` in order to parse the burp-clients configuration files. diff --git a/burpui/misc/parser/utils.py b/burpui/misc/parser/utils.py index b355105f..9b38f68f 100644 --- a/burpui/misc/parser/utils.py +++ b/burpui/misc/parser/utils.py @@ -23,6 +23,8 @@ from ...datastructures import MultiDict RESET_IDENTIFIER = '_reset_bui_CUSTOM' +BEGIN_TEMPLATES = 'BURP-UI TEMPLATES' +END_TEMPLATES = 'END TEMPLATES' class Option(object): @@ -272,7 +274,7 @@ class OptionInc(Option): def _path_absolute(self, path): absolute = path - if not path.startswith('/'): + if not os.path.isabs(path): if self.root: absolute = os.path.join(self.root, path) elif self.mode == 'srv': @@ -321,6 +323,60 @@ class OptionInc(Option): return '' +class OptionTpl(Option): + """Option type Template + + Example: + . .buitemplates/windows + """ + type = 'template' + delim = "" + + def __init__(self, parser, name, value=None): + """ + :param parser: Parser instance + :type parser: :class:`burpui.misc.parser.burp1.Parser` + """ + super(OptionTpl, self).__init__(name, value) + self.parser = parser + self.extended = False + self._dirty = True + if name: + self._id = name.split(os.path.sep)[-1] + else: + self._id = '' + + @property + def dirty(self): + return self._dirty + + def _path_absolute(self, path): + absolute = path + if not os.path.isabs(path): + absolute = os.path.join(self.parser.clientconfdir, path) + return absolute + + def extend(self): + """Helper function for the parsing""" + if not self._dirty and self.extended: + return self.extended + path = self._path_absolute(self.value) + self.clean() + self.extended = path + return path + + def parse(self): + """Parse the option value""" + return self.extend() + + def dump(self): + """Return the option representation to store in configuration file""" + if self.extend() and not self.parser.backend.enforce: + return '. {}'.format(self.name) + # if the include did not match anything, we can safely remove it + return '' + + class File(dict): """Object representing a configuration file @@ -345,6 +401,8 @@ class File(dict): self._dirty = False # _changed is used to know if the file changed since last read self._changed = True + # _parsing_templates is used to know if we are currently parsing templates + self._parsing_templates = False # cache the content of the file self._raw = [] self._raw_data = MultiDict() @@ -362,6 +420,7 @@ class File(dict): 'include': OrderedDict(), 'multi': OrderedDict(), 'string': OrderedDict(), + 'template': OrderedDict(), } if self.name: self.parse() @@ -372,6 +431,10 @@ class File(dict): if val.dirty: self._changed = True return self._changed + for key, val in iteritems(self.types['template']): + if val.dirty: + self._changed = True + return self._changed try: if self.name: mtime = os.path.getmtime(self.name) @@ -463,6 +526,16 @@ class File(dict): def include(self): return self.flatten('include') + @property + def template(self): + ret = [] + for tpl in self.flatten('template', parse=False): + ret.append({ + 'value': tpl['name'], + 'name': tpl['value']._id, + }) + return ret + @property def multi(self): return self.flatten('multi') @@ -506,7 +579,7 @@ class File(dict): return opt if typ == 'include': key = value - opt = OptionInc( + return OptionInc( self.parser, key, value, @@ -548,13 +621,16 @@ class File(dict): opt.append(value) elif key == u'.': key = value - opt = OptionInc( - self.parser, - key, - value, - root=self.name, - mode=self.mode - ) + if self._parsing_templates: + opt = OptionTpl(self.parser, key, value) + else: + opt = OptionInc( + self.parser, + key, + value, + root=self.name, + mode=self.mode + ) else: opt = OptionStr(key, value) self.options[key] = opt @@ -698,6 +774,10 @@ class File(dict): self.clear() for line in self.raw: if re.match(r'^\s*#', line): + if BEGIN_TEMPLATES in line: + self._parsing_templates = True + if END_TEMPLATES in line: + self._parsing_templates = False continue res = re.search(r'\s*([^=\s]+)\s*(:)?=?\s*(.*)$', line) if res: @@ -821,7 +901,21 @@ class File(dict): with codecs.open(dest, 'w', 'utf-8', errors='ignore') as fil: # f.write('# Auto-generated configuration using Burp-UI\n') data_keys = list(data.keys()) + if len(self.template) > 0 or 'templates' in data: + _dump(' {}'.format(BEGIN_TEMPLATES), True) + tpls = data.getlist('templates') or [x['value'] for x in self.template] + for tpl in tpls: + self._write_key(fil, '.', tpl) + _dump(' {}'.format(END_TEMPLATES), True) + skip_line = False for idx, line in enumerate(orig): + if self._line_is_comment(line) and BEGIN_TEMPLATES in line: + skip_line = True + if self._line_is_comment(line) and END_TEMPLATES in line: + skip_line = False + continue + if skip_line: + continue key = self._get_line_key(line, False) if (self._line_removed(line, data_keys) and not self._line_is_comment(line) and @@ -917,7 +1011,7 @@ class File(dict): if key.endswith(RESET_IDENTIFIER): continue if (key not in written and key not in already_multi and - key not in ['includes', 'includes_ori']): + key not in ['includes', 'includes_ori', 'templates']): self._write_key( fil, key, @@ -1016,6 +1110,7 @@ class Config(File): self.default = path self.name = path self._includes = [] + self._templates = [] self._dirty = True if path: self.files[path] = File(parser, path, mode=mode) @@ -1039,6 +1134,11 @@ class Config(File): path = os.path.join(os.path.dirname(root), path) self.add_file(path, root) self._includes.append(path) + for key, path in iteritems(conf.flatten('template', False)): + if not os.path.isabs(path): + path = os.path.join(os.path.dirname(root), path) + self.add_file(path, root) + self._templates.append(path) # recursively parse the conf if orig != self.files: @@ -1049,13 +1149,14 @@ class Config(File): return del self._includes[:] + del self._templates[:] self._parse() removed = [] orig = self.files for path, conf in iteritems(orig): - if conf.parent and (conf.name not in self._includes or - conf.name in removed): + if conf.parent and ((conf.name not in self._includes and + conf.name not in self._templates) or conf.name in removed): removed.append(path) self.del_file(path) @@ -1110,7 +1211,7 @@ class Config(File): if conf and conf in self.files: return self.files[conf].store(dest, insecure) for name, conf in iteritems(self.files): - ret += conf.store(dest, insecure) + ret += conf.store(insecure=insecure) return ret def store_data(self, conf, data, insecure=False): diff --git a/burpui/routes.py b/burpui/routes.py index 0b0dd632..3d317d58 100644 --- a/burpui/routes.py +++ b/burpui/routes.py @@ -169,9 +169,11 @@ def cli_settings(server=None, client=None, conf=None): pass client = client or request.args.get('client') server = server or request.args.get('serverName') + template = request.args.get('template') or False return render_template( 'settings.html', settings=True, + template=template, client=client, server=server, conf=conf, diff --git a/burpui/templates/js/settings.js b/burpui/templates/js/settings.js index 70e1a57e..a6a732fa 100644 --- a/burpui/templates/js/settings.js +++ b/burpui/templates/js/settings.js @@ -97,6 +97,7 @@ * } * The JSON is then split-ed out into several dict/arrays to build our form. */ +{% import 'macros.html' as macros %} var app = angular.module('MainApp', ['ngSanitize', 'frapontillo.bootstrap-switch', 'ui.select', 'mgcrea.ngStrap', 'angular-onbeforeunload', 'datatables']); @@ -104,7 +105,7 @@ app.config(function(uiSelectConfig) { uiSelectConfig.theme = 'bootstrap'; }); -app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, $http, $scrollspy, DTOptionsBuilder, DTColumnDefBuilder) { +app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', 'DTOptionsBuilder', 'DTColumnDefBuilder', function($scope, $http, $scrollspy, DTOptionsBuilder, DTColumnDefBuilder) { $scope.bools = []; $scope.strings = []; $scope.clients = []; @@ -120,63 +121,75 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, $scope.revokeEnabled = false; $scope.inc_invalid = {}; $scope.old = {}; + $scope.raw = {}; $scope.spy = {}; $scope.new = { 'bools': undefined, 'integers': undefined, 'strings': undefined, - 'multis': undefined + 'multis': undefined, + 'templates': undefined }; $scope.add = { 'bools': false, 'integers': false, 'strings': false, - 'multis': false + 'multis': false, + 'templates': false }; $scope.changed = false; $scope.checkbox_translation = { 'yes': "{{ _('yes') }}", 'no': "{{ _('no') }}", - 'reset': "{{ _('Reset list') }}", + 'reset': "{{ _('reset list') }}", + }; + $scope.dtOptions = { + {{ macros.translate_datatable() }} + {{ macros.get_page_length() }} }; - $scope.dtOptions = DTOptionsBuilder.newOptions(); $scope.dtColumnDefs = [ DTColumnDefBuilder.newColumnDef(0), DTColumnDefBuilder.newColumnDef(1), DTColumnDefBuilder.newColumnDef(2).notSortable(), ]; - {% if client -%} - $http.get('{{ url_for("api.client_settings", client=client, conf=conf, server=server) }}', { headers: { 'X-From-UI': true } }) - {% else -%} - $http.get('{{ url_for("api.server_settings", conf=conf, server=server) }}', { headers: { 'X-From-UI': true } }) - {% endif -%} - .then(function(response) { - data = response.data; - $scope.bools = data.results.boolean; - $scope.all.bools = data.boolean; - $scope.strings = data.results.common; - $scope.all.strings = data.string; - $scope.integers = data.results.integer; - $scope.all.integers = data.integer; - $scope.multis = data.results.multi; - $scope.all.multis = data.multi; - $scope.clients = data.results.clients; - $scope.server_doc = data.server_doc; - $scope.suggest = data.suggest; - $scope.placeholders = data.placeholders; - $scope.defaults = data.defaults; - $scope.includes = data.results.includes; - $scope.includes_ori = angular.copy($scope.includes); - $scope.includes_ext = data.results.includes_ext; - $scope.hierarchy = data.results.hierarchy; - $scope.refreshHierarchy(); - $scope.refreshScrollspy(); - $('#waiting-container').hide(); - $('#settings-panel').show(); - }, function(response) { - notifAll(response.data); - $('#waiting-container').hide(); - }); + $scope.loadConfig = function() { + {% if client -%} + {% if template -%} + $http.get('{{ url_for("api.client_settings", client=client, conf=conf, template=True, server=server) }}', { headers: { 'X-From-UI': true } }) + {% else -%} + $http.get('{{ url_for("api.client_settings", client=client, conf=conf, server=server) }}', { headers: { 'X-From-UI': true } }) + {% endif -%} + {% else -%} + $http.get('{{ url_for("api.server_settings", conf=conf, server=server) }}', { headers: { 'X-From-UI': true } }) + {% endif -%} + .then(function(response) { + data = response.data; + $scope.bools = data.results.boolean; + $scope.all.bools = data.boolean; + $scope.strings = data.results.common; + $scope.all.strings = data.string; + $scope.integers = data.results.integer; + $scope.all.integers = data.integer; + $scope.multis = data.results.multi; + $scope.all.multis = data.multi; + $scope.server_doc = data.server_doc; + $scope.suggest = data.suggest; + $scope.placeholders = data.placeholders; + $scope.defaults = data.defaults; + $scope.includes = data.results.includes; + $scope.includes_ori = angular.copy($scope.includes); + $scope.includes_ext = data.results.includes_ext; + $scope.templates = data.results.templates; + $scope.hierarchy = data.results.hierarchy; + $scope.refreshHierarchy(); + $scope.refreshScrollspy(); + $('#waiting-container').hide(); + $('#settings-panel').show(); + }, function(response) { + notifAll(response.data); + $('#waiting-container').hide(); + }); + }; $http.get('{{ url_for("api.setting_options", server=server) }}', { headers: { 'X-From-UI': true } }) .then(function(response) { $scope.revokeEnabled = response.data.is_revocation_enabled; @@ -345,14 +358,24 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, $scope.new[type] = undefined; } $scope.add[type] = true; + all = $scope.all[type]; + if (type === 'templates') { + all = []; + _($scope.all[type]).forEach(function(value, name) { + all.push(name); + }); + } keys = _.map($scope[type], 'name'); - diff = _.difference($scope.all[type], keys); + diff = _.difference(all, keys); $scope.avail[type] = []; _(diff).forEach(function(n) { v = $scope.defaults[n]; - if (!v && type == 'multis') { + if (!v && type === 'multis') { v = ['']; } + if (!v && type === 'templates') { + v = $scope.all[type][n]; + } $scope.avail[type].push({'name': n, 'value': v}); }); }; @@ -367,6 +390,7 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, $scope.add.multis = false; $scope.new.multis = false; $scope.changed = true; + $scope.refreshScrollspy(); }; $scope.addMulti = function(pindex) { $scope.multis[pindex].value.push(''); @@ -386,6 +410,8 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, $scope.old.includes_ori.push($scope.includes_ori[index]); $scope.includes.splice(index, 1); $scope.includes_ori.splice(index, 1); + $scope.changed = true; + $scope.refreshScrollspy(); }; $scope.clickAddIncludes = function() { val = ''; @@ -398,6 +424,8 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, } $scope.includes.push(val); $scope.includes_ori.push(val2); + $scope.changed = true; + $scope.refreshScrollspy(); }; $scope.select = function(selected, select, type) { select.search = undefined; @@ -405,6 +433,8 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, selected.value = $scope.old[type][selected.name]; } $scope[type].push(selected); + console.log(selected); + console.log($scope[type]); $scope.add[type] = false; $scope.changed = true; }; @@ -458,19 +488,46 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, }; $scope.getClientsList = function() { api = '{{ url_for("api.clients_list", server=server) }}'; - $.ajax({ - url: api, - type: 'GET' - }).done(function(data) { - $scope.clients = data.result; - }); + $http.get( + api, + { + headers: { 'X-From-UI': true }, + } + ).then( + function(response) { + var data = response.data; + $scope.clients = data.result; + } + ); + }; + $scope.getTemplatesList = function() { + api = '{{ url_for("api.templates_list", server=server) }}'; + $http.get( + api, + { + headers: { 'X-From-UI': true }, + } + ).then( + function(response) { + var data = response.data; + $scope.raw.templates = data.result; + $scope.all.templates = {}; + _(data.result).forEach(function(r) { + $scope.all.templates[r.name] = r.value; + }); + } + ); }; $scope.deleteClient = function() { api = '{{ url_for("api.client_settings", client=client, server=server) }}'; $.ajax({ url: api, type: 'DELETE', + {% if template -%} + data: { template: true } + {% else -%} data: { delcert: $('#delcert').is(':checked'), revoke: $('#revoke').is(':checked'), keepconf: $('#keepconf').is(':checked') } + {% endif -%} }) .fail(myFail) .done(function(data) { @@ -503,6 +560,28 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, } }); }; + $scope.createTemplate = function(e) { + /* we disable the 'real' form submission */ + e.preventDefault(); + var form = $(e.target); + $.ajax({ + url: form.attr('action'), + type: 'PUT', + data: form.serialize() + }) + .fail(myFail) + .done(function(data) { + /* The server answered correctly but some errors may have occurred server + * side so we display them */ + if (data.notif) { + notif(data.notif[0][0], data.notif[0][1]); + if (data.notif[0][0] == NOTIF_SUCCESS) { + $scope.getTemplatesList(); + notif(data.notif[1][0], data.notif[1][1], 20000); + } + } + }); + }; /* These callbacks expand/reduce the input for a better readability */ $scope.focusIn = function(ev) { el = $( ev.target ).parent(); @@ -522,8 +601,14 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$scrollspy', function($scope, el.next('div').next('div').next('div').show(); el.removeClass('col-lg-9').addClass('col-lg-2'); }; + $scope.loadConfig(); + $scope.getClientsList(); + $scope.getTemplatesList(); }]); +{{ macros.page_length('#table-list-clients') }} +{{ macros.page_length('#table-list-templates') }} + $(document).ready(function () { $('#config-nav a').click(function (e) { e.preventDefault(); diff --git a/burpui/templates/layout.html b/burpui/templates/layout.html index 566f1aeb..bdefebf8 100644 --- a/burpui/templates/layout.html +++ b/burpui/templates/layout.html @@ -103,6 +103,7 @@ + {% endif -%} {% if tree or settings -%}