diff --git a/config/modules.json.sample b/config/modules.json.sample index a927945a..dcb8bae7 100644 --- a/config/modules.json.sample +++ b/config/modules.json.sample @@ -23,16 +23,23 @@ "enabled": true, "allow_auto_trigger": true }, - "MISP": { - "apikey": null, - "url": "https://misp.url", - "verify_tls_cert": true, - "timeout": 10, - "enable_lookup": false, - "enable_push": false, - "default_tags": [], - "auto_publish": false, - "allow_auto_trigger": false + "MultipleMISPs": { + "default": "MISP", + "instances": { + "MISP": { + "apikey": null, + "url": "https://misp.url", + "verify_tls_cert": true, + "timeout": 10, + "enable_lookup": false, + "enable_push": false, + "default_tags": [ + "source:lookyloo" + ], + "auto_publish": false, + "allow_auto_trigger": false + } + } }, "UniversalWhois": { "enabled": false, diff --git a/lookyloo/lookyloo.py b/lookyloo/lookyloo.py index fae0e194..dfe7335d 100644 --- a/lookyloo/lookyloo.py +++ b/lookyloo/lookyloo.py @@ -52,7 +52,7 @@ from .helpers import (get_captures_dir, get_email_template, uniq_domains, ParsedUserAgent, load_cookies, UserAgents, get_useragent_for_requests) from .indexing import Indexing -from .modules import (MISP, PhishingInitiative, UniversalWhois, +from .modules import (MISPs, MISP, PhishingInitiative, UniversalWhois, UrlScan, VirusTotal, Phishtank, Hashlookup, RiskIQ, RiskIQError, Pandora, URLhaus) @@ -106,9 +106,33 @@ class Lookyloo(): if not self.vt.available: self.logger.warning('Unable to setup the VirusTotal module') - self.misp = MISP(get_config('modules', 'MISP')) - if not self.misp.available: - self.logger.warning('Unable to setup the MISP module') + # ## Initialize MISP(s) + try_old_config = False + if misps_config := get_config('modules', 'MultipleMISPs'): + # New config + self.misps = MISPs(misps_config) + if not self.misps.available: + self.logger.warning('Unable to setup the MISP module') + try_old_config = True + + if try_old_config: + # Legacy MISP config, now use MultipleMISPs key to support more than one MISP instance + try: + if misp_config := get_config('modules', 'MISP'): + misps_config = {'default': 'MISP', 'instances': {'MISP': misp_config}} + self.misps = MISPs(misps_config) + if self.misps.available: + self.logger.warning('Please migrate the MISP config to the "MultipleMISPs" key in the config, and remove the "MISP" key') + else: + self.logger.warning('Unable to setup the MISP module') + except Exception: + # The key was removed from the config, and the sample config + pass + + if not self.misps.available: + self.logger.info('The MISP module is not configured') + + # ## Done with MISP(s) self.uwhois = UniversalWhois(get_config('modules', 'UniversalWhois')) if not self.uwhois.available: @@ -1129,9 +1153,9 @@ class Lookyloo(): # In the case, we want to have it as a FileObject in the export filename, pseudofile = self.get_data(capture_uuid) if filename: - event = self.misp.export(cache, self.is_public_instance, filename, pseudofile) + event = self.misps.export(cache, self.is_public_instance, filename, pseudofile) else: - event = self.misp.export(cache, self.is_public_instance) + event = self.misps.export(cache, self.is_public_instance) screenshot: MISPAttribute = event.add_attribute('attachment', 'screenshot_landing_page.png', data=self.get_screenshot(cache.uuid), disable_correlation=True) # type: ignore @@ -1179,8 +1203,18 @@ class Lookyloo(): return [event] - def get_misp_occurrences(self, capture_uuid: str, /) -> Optional[Dict[str, Set[str]]]: - if not self.misp.available: + def get_misp_instance(self, instance_name: Optional[str]=None) -> MISP: + if instance_name: + if misp := self.misps.get(instance_name): + return misp + self.logger.warning(f'Unable to connect to MISP Instance {instance_name}, falling back to default.') + + return self.misps.default_misp + + def get_misp_occurrences(self, capture_uuid: str, /, *, instance_name: Optional[str]=None) -> Optional[Tuple[Dict[str, Set[str]], str]]: + misp = self.get_misp_instance(instance_name) + + if not misp.available: return None try: ct = self.get_crawled_tree(capture_uuid) @@ -1190,12 +1224,12 @@ class Lookyloo(): nodes_to_lookup = ct.root_hartree.rendered_node.get_ancestors() + [ct.root_hartree.rendered_node] to_return: Dict[str, Set[str]] = defaultdict(set) for node in nodes_to_lookup: - hits = self.misp.lookup(node, ct.root_hartree.get_host_node_by_uuid(node.hostnode_uuid)) + hits = misp.lookup(node, ct.root_hartree.get_host_node_by_uuid(node.hostnode_uuid)) for event_id, values in hits.items(): if not isinstance(values, set): continue to_return[event_id].update(values) - return to_return + return to_return, misp.client.root_url def get_hashes_with_context(self, tree_uuid: str, /, algorithm: str, *, urls_only: bool=False) -> Union[Dict[str, Set[str]], Dict[str, List[URLNode]]]: """Build (on demand) hashes for all the ressources of the tree, using the alorighm provided by the user. diff --git a/lookyloo/modules/__init__.py b/lookyloo/modules/__init__.py index bd84ea49..f5c2b973 100644 --- a/lookyloo/modules/__init__.py +++ b/lookyloo/modules/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from .fox import FOX # noqa -from .misp import MISP # noqa +from .misp import MISPs, MISP # noqa from .pi import PhishingInitiative # noqa from .sanejs import SaneJavaScript # noqa from .urlscan import UrlScan # noqa diff --git a/lookyloo/modules/misp.py b/lookyloo/modules/misp.py index 51a92d2e..79c051ae 100644 --- a/lookyloo/modules/misp.py +++ b/lookyloo/modules/misp.py @@ -5,6 +5,7 @@ import re from io import BytesIO from collections import defaultdict +from collections.abc import Mapping from typing import Any, Dict, List, Optional, Set, Union, TYPE_CHECKING import requests @@ -19,6 +20,137 @@ if TYPE_CHECKING: from ..capturecache import CaptureCache +class MISPs(Mapping): + + def __init__(self, config: Dict[str, Any]): + self.logger = logging.getLogger(f'{self.__class__.__name__}') + self.logger.setLevel(get_config('generic', 'loglevel')) + + if not config.get('default'): + self.available = False + self.logger.info('No default instance configured, disabling MISP.') + return + if not config.get('instances'): + self.available = False + self.logger.warning('No MISP instances configured, disabling MISP.') + return + + self.default_instance = config['default'] + + if self.default_instance not in config['instances']: + self.available = False + self.logger.warning(f"The default MISP instance ({self.default_instance}) is missing in the instances ({', '.join(config['instances'].keys())}), disabling MISP.") + return + + self.__misps: Dict[str, 'MISP'] = {} + for instance_name, instance_config in config['instances'].items(): + if misp_connector := MISP(instance_config): + if misp_connector.available: + self.__misps[instance_name] = misp_connector + else: + self.logger.warning(f"MISP '{instance_name}' isn't available.") + else: + self.logger.warning(f"Unable to initialize the connector to '{instance_name}'. It won't be available.") + + if not self.__misps.get(self.default_instance) or not self.__misps[self.default_instance].available: + self.available = False + self.logger.warning("Unable to initialize the connector to the default MISP instance, disabling MISP.") + return + + self.available = True + + def __getitem__(self, name: str) -> 'MISP': + return self.__misps[name] + + def __iter__(self): + return iter(self.__misps) + + def __len__(self): + return len(self.__misps) + + @property + def default_misp(self) -> 'MISP': + return self.__misps[self.default_instance] + + def export(self, cache: 'CaptureCache', is_public_instance: bool=False, + submitted_filename: Optional[str]=None, + submitted_file: Optional[BytesIO]=None) -> MISPEvent: + '''Export a capture in MISP format. You can POST the return of this method + directly to a MISP instance and it will create an event.''' + public_domain = get_config('generic', 'public_domain') + event = MISPEvent() + if cache.url.startswith('file'): + filename = cache.url.rsplit('/', 1)[-1] + event.info = f'Lookyloo Capture ({filename})' + # Create file object as initial + if hasattr(cache.tree.root_hartree.url_tree, 'body'): + # The file could be viewed in the browser + filename = cache.tree.root_hartree.url_tree.name + pseudofile = cache.tree.root_hartree.url_tree.body + elif submitted_filename: + # Impossible to get the file from the HAR. + filename = submitted_filename + pseudofile = submitted_file + else: + raise Exception('We must have a file here.') + + initial_file = FileObject(pseudofile=pseudofile, filename=filename) + initial_file.comment = 'This is a capture of a file, rendered in the browser' + initial_obj = event.add_object(initial_file) + else: + event.info = f'Lookyloo Capture ({cache.url})' + initial_url = URLObject(cache.url) + initial_url.comment = 'Submitted URL' + self.__misp_add_ips_to_URLObject(initial_url, cache.tree.root_hartree.hostname_tree) + initial_obj = event.add_object(initial_url) + + lookyloo_link: MISPAttribute = event.add_attribute('link', f'https://{public_domain}/tree/{cache.uuid}') # type: ignore + if not is_public_instance: + lookyloo_link.distribution = 0 + initial_obj.add_reference(lookyloo_link, 'captured-by', 'Capture on lookyloo') + + redirects: List[URLObject] = [] + for nb, url in enumerate(cache.redirects): + if url == cache.url: + continue + obj = URLObject(url) + obj.comment = f'Redirect {nb}' + self.__misp_add_ips_to_URLObject(obj, cache.tree.root_hartree.hostname_tree) + redirects.append(obj) + + if redirects: + redirects[-1].comment = f'Last redirect ({nb})' + + if redirects: + prec_object = initial_url + for u_object in redirects: + prec_object.add_reference(u_object, 'redirects-to') + prec_object = u_object + + for u_object in redirects: + event.add_object(u_object) + final_redirect = event.objects[-1] + + try: + fo = FileObject(pseudofile=cache.tree.root_hartree.rendered_node.body, filename=cache.tree.root_hartree.rendered_node.filename) + fo.comment = 'Content received for the final redirect (before rendering)' + fo.add_reference(final_redirect, 'loaded-by', 'URL loading that content') + event.add_object(fo) + except Har2TreeError: + pass + except AttributeError: + # No `body` in rendered node + pass + return event + + def __misp_add_ips_to_URLObject(self, obj: URLObject, hostname_tree: HostNode) -> None: + hosts = obj.get_attributes_by_relation('host') + if hosts: + hostnodes = hostname_tree.search_nodes(name=hosts[0].value) + if hostnodes and hasattr(hostnodes[0], 'resolved_ips'): + obj.add_attributes('ip', *hostnodes[0].resolved_ips) + + class MISP(): def __init__(self, config: Dict[str, Any]): @@ -159,81 +291,3 @@ class MISP(): return {'info': 'No hits.'} else: return {'error': 'Module not available or lookup not enabled.'} - - def __misp_add_ips_to_URLObject(self, obj: URLObject, hostname_tree: HostNode) -> None: - hosts = obj.get_attributes_by_relation('host') - if hosts: - hostnodes = hostname_tree.search_nodes(name=hosts[0].value) - if hostnodes and hasattr(hostnodes[0], 'resolved_ips'): - obj.add_attributes('ip', *hostnodes[0].resolved_ips) - - def export(self, cache: 'CaptureCache', is_public_instance: bool=False, - submitted_filename: Optional[str]=None, - submitted_file: Optional[BytesIO]=None) -> MISPEvent: - '''Export a capture in MISP format. You can POST the return of this method - directly to a MISP instance and it will create an event.''' - public_domain = get_config('generic', 'public_domain') - event = MISPEvent() - if cache.url.startswith('file'): - filename = cache.url.rsplit('/', 1)[-1] - event.info = f'Lookyloo Capture ({filename})' - # Create file object as initial - if hasattr(cache.tree.root_hartree.url_tree, 'body'): - # The file could be viewed in the browser - filename = cache.tree.root_hartree.url_tree.name - pseudofile = cache.tree.root_hartree.url_tree.body - elif submitted_filename: - # Impossible to get the file from the HAR. - filename = submitted_filename - pseudofile = submitted_file - else: - raise Exception('We must have a file here.') - - initial_file = FileObject(pseudofile=pseudofile, filename=filename) - initial_file.comment = 'This is a capture of a file, rendered in the browser' - initial_obj = event.add_object(initial_file) - else: - event.info = f'Lookyloo Capture ({cache.url})' - initial_url = URLObject(cache.url) - initial_url.comment = 'Submitted URL' - self.__misp_add_ips_to_URLObject(initial_url, cache.tree.root_hartree.hostname_tree) - initial_obj = event.add_object(initial_url) - - lookyloo_link: MISPAttribute = event.add_attribute('link', f'https://{public_domain}/tree/{cache.uuid}') # type: ignore - if not is_public_instance: - lookyloo_link.distribution = 0 - initial_obj.add_reference(lookyloo_link, 'captured-by', 'Capture on lookyloo') - - redirects: List[URLObject] = [] - for nb, url in enumerate(cache.redirects): - if url == cache.url: - continue - obj = URLObject(url) - obj.comment = f'Redirect {nb}' - self.__misp_add_ips_to_URLObject(obj, cache.tree.root_hartree.hostname_tree) - redirects.append(obj) - - if redirects: - redirects[-1].comment = f'Last redirect ({nb})' - - if redirects: - prec_object = initial_url - for u_object in redirects: - prec_object.add_reference(u_object, 'redirects-to') - prec_object = u_object - - for u_object in redirects: - event.add_object(u_object) - final_redirect = event.objects[-1] - - try: - fo = FileObject(pseudofile=cache.tree.root_hartree.rendered_node.body, filename=cache.tree.root_hartree.rendered_node.filename) - fo.comment = 'Content received for the final redirect (before rendering)' - fo.add_reference(final_redirect, 'loaded-by', 'URL loading that content') - event.add_object(fo) - except Har2TreeError: - pass - except AttributeError: - # No `body` in rendered node - pass - return event diff --git a/website/web/__init__.py b/website/web/__init__.py index ad86107b..cfdc77d3 100644 --- a/website/web/__init__.py +++ b/website/web/__init__.py @@ -354,32 +354,61 @@ def stats(tree_uuid: str): @app.route('/tree//misp_lookup', methods=['GET']) @flask_login.login_required def web_misp_lookup_view(tree_uuid: str): - hits = lookyloo.get_misp_occurrences(tree_uuid) - if hits: - misp_root_url = lookyloo.misp.client.root_url - else: - misp_root_url = '' - return render_template('misp_lookup.html', uuid=tree_uuid, hits=hits, misp_root_url=misp_root_url) + if not lookyloo.misps.available: + flash('There are no MISP instances available.', 'error') + return redirect(url_for('tree', tree_uuid=tree_uuid)) + misps_occurrences = {} + for instance_name in lookyloo.misps: + if occurrences := lookyloo.get_misp_occurrences(tree_uuid, instance_name=instance_name): + misps_occurrences[instance_name] = occurrences + return render_template('misp_lookup.html', uuid=tree_uuid, + current_misp=lookyloo.misps.default_instance, + misps_occurrences=misps_occurrences) @app.route('/tree//misp_push', methods=['GET', 'POST']) @flask_login.login_required def web_misp_push_view(tree_uuid: str): - error = False - if not lookyloo.misp.available: - flash('MISP module not available.', 'error') + if not lookyloo.misps.available: + flash('There are no MISP instances available.', 'error') return redirect(url_for('tree', tree_uuid=tree_uuid)) - elif not lookyloo.misp.enable_push: - flash('Push not enabled in MISP module.', 'error') - return redirect(url_for('tree', tree_uuid=tree_uuid)) - else: - event = lookyloo.misp_export(tree_uuid) - if isinstance(event, dict): - flash(f'Unable to generate the MISP export: {event}', 'error') - return redirect(url_for('tree', tree_uuid=tree_uuid)) - if request.method == 'POST': + event = lookyloo.misp_export(tree_uuid) + if isinstance(event, dict): + flash(f'Unable to generate the MISP export: {event}', 'error') + return redirect(url_for('tree', tree_uuid=tree_uuid)) + + if request.method == 'GET': + # Initialize settings that will be displayed on the template + misp_instances_settings = {} + for name, instance in lookyloo.misps.items(): + # the 1st attribute in the event is the link to lookyloo + misp_instances_settings[name] = { + 'default_tags': instance.default_tags, + 'fav_tags': [tag.name for tag in instance.get_fav_tags()], + 'auto_publish': instance.auto_publish + } + if existing_misp_url := instance.get_existing_event_url(event[-1].attributes[0].value): + misp_instances_settings[name]['existing_event'] = existing_misp_url + + cache = lookyloo.capture_cache(tree_uuid) + return render_template('misp_push_view.html', + current_misp=lookyloo.misps.default_instance, + tree_uuid=tree_uuid, + event=event[0], + misp_instances_settings=misp_instances_settings, + has_parent=True if cache and cache.parent else False) + + elif request.method == 'POST': # event is a MISPEvent at this point + misp_instance_name = request.form.get('misp_instance_name') + if not misp_instance_name or misp_instance_name not in lookyloo.misps: + flash(f'MISP instance {misp_instance_name} is unknown.', 'error') + return redirect(url_for('tree', tree_uuid=tree_uuid)) + misp = lookyloo.misps[misp_instance_name] + if not misp.enable_push: + flash('Push not enabled in MISP module.', 'error') + return redirect(url_for('tree', tree_uuid=tree_uuid)) # Submit the event tags = request.form.getlist('tags') error = False @@ -406,30 +435,17 @@ def web_misp_push_view(tree_uuid: str): events[-1].info = request.form.get('event_info') try: - new_events = lookyloo.misp.push(events, True if request.form.get('force_push') else False, - True if request.form.get('auto_publish') else False) + new_events = misp.push(events, True if request.form.get('force_push') else False, + True if request.form.get('auto_publish') else False) except MISPServerError: - flash(f'MISP returned an error, the event(s) might still have been created on {lookyloo.misp.client.root_url}', 'error') + flash(f'MISP returned an error, the event(s) might still have been created on {misp.client.root_url}', 'error') else: if isinstance(new_events, dict): flash(f'Unable to create event(s): {new_events}', 'error') else: for e in new_events: - flash(f'MISP event {e.id} created on {lookyloo.misp.client.root_url}', 'success') + flash(f'MISP event {e.id} created on {misp.client.root_url}', 'success') return redirect(url_for('tree', tree_uuid=tree_uuid)) - else: - # the 1st attribute in the event is the link to lookyloo - existing_misp_url = lookyloo.misp.get_existing_event_url(event[-1].attributes[0].value) - - fav_tags = lookyloo.misp.get_fav_tags() - cache = lookyloo.capture_cache(tree_uuid) - - return render_template('misp_push_view.html', tree_uuid=tree_uuid, - event=event[0], fav_tags=fav_tags, - existing_event=existing_misp_url, - auto_publish=lookyloo.misp.auto_publish, - has_parent=True if cache and cache.parent else False, - default_tags=lookyloo.misp.default_tags) @app.route('/tree//modules', methods=['GET']) @@ -786,8 +802,8 @@ def tree(tree_uuid: str, node_uuid: Optional[str]=None): enable_context_by_users=enable_context_by_users, enable_categorization=enable_categorization, enable_bookmark=enable_bookmark, - misp_push=lookyloo.misp.available and lookyloo.misp.enable_push, - misp_lookup=lookyloo.misp.available and lookyloo.misp.enable_lookup, + misp_push=lookyloo.misps.available and lookyloo.misps.default_misp.enable_push, + misp_lookup=lookyloo.misps.available and lookyloo.misps.default_misp.enable_lookup, blur_screenshot=blur_screenshot, urlnode_uuid=hostnode_to_highlight, auto_trigger_modules=auto_trigger_modules, confirm_message=confirm_message if confirm_message else 'Tick to confirm.', diff --git a/website/web/genericapi.py b/website/web/genericapi.py index 449288a2..12404953 100644 --- a/website/web/genericapi.py +++ b/website/web/genericapi.py @@ -180,6 +180,7 @@ misp_push_fields = api.model('MISPPushFields', { @api.route('/json//misp_push') +@api.route('/json//misp_push/') @api.doc(description='Push an event to a pre-configured MISP instance', params={'capture_uuid': 'The UUID of the capture'}, security='apikey') @@ -188,20 +189,21 @@ class MISPPush(Resource): @api.param('with_parents', 'Also push the parents of the capture (if any)') @api.param('allow_duplicates', 'Push the event even if it is already present on the MISP instance') - def get(self, capture_uuid: str): + def get(self, capture_uuid: str, instance_name: Optional[str]=None): with_parents = True if request.args.get('with_parents') else False allow_duplicates = True if request.args.get('allow_duplicates') else False to_return: Dict = {} - if not lookyloo.misp.available: + misp = self.get_misp_instance(instance_name) + if not misp.available: to_return['error'] = 'MISP module not available.' - elif not lookyloo.misp.enable_push: + elif not misp.enable_push: to_return['error'] = 'Push not enabled in MISP module.' else: event = lookyloo.misp_export(capture_uuid, with_parents) if isinstance(event, dict): to_return['error'] = event else: - new_events = lookyloo.misp.push(event, allow_duplicates) + new_events = misp.push(event, allow_duplicates) if isinstance(new_events, dict): to_return['error'] = new_events else: @@ -213,22 +215,23 @@ class MISPPush(Resource): return to_return @api.doc(body=misp_push_fields) - def post(self, capture_uuid: str): + def post(self, capture_uuid: str, instance_name: Optional[str]=None): parameters: Dict = request.get_json(force=True) with_parents = True if parameters.get('with_parents') else False allow_duplicates = True if parameters.get('allow_duplicates') else False to_return: Dict = {} - if not lookyloo.misp.available: + misp = self.get_misp_instance(instance_name) + if not misp.available: to_return['error'] = 'MISP module not available.' - elif not lookyloo.misp.enable_push: + elif not misp.enable_push: to_return['error'] = 'Push not enabled in MISP module.' else: event = lookyloo.misp_export(capture_uuid, with_parents) if isinstance(event, dict): to_return['error'] = event else: - new_events = lookyloo.misp.push(event, allow_duplicates) + new_events = misp.push(event, allow_duplicates) if isinstance(new_events, dict): to_return['error'] = new_events else: diff --git a/website/web/templates/misp_lookup.html b/website/web/templates/misp_lookup.html index 50a75a73..7cc47910 100644 --- a/website/web/templates/misp_lookup.html +++ b/website/web/templates/misp_lookup.html @@ -1,11 +1,24 @@ -{% from "macros.html" import shorten_string %} +
+

MISP hits

+
Searching on URL, domain, IPs, and CNAMEs for all the nodes up to the rendered page.
+
Skips the entries in warnings lists enabled on your MISP instance.
+{% if misps_occurrences|length > 1 %} +
+
+ +
+
+ {%for name in misps_occurrences %} +
+{%endif%} +
-
-
-

MISP hits

-
Searching on URL, domain, IPs, and CNAMEs for all the nodes up to the rendered page.
-
Skips the entries in warnings lists enabled on your MISP instance.
-
+
+{% for name, occurrences in misps_occurrences.items() %} +
+{% set hits, root_url = occurrences %} {% if hits %}
    {% for event_id, values in hits.items() %} @@ -21,3 +34,6 @@ {% else %} No hits {% endif %} +
+{% endfor %} +
diff --git a/website/web/templates/misp_push_view.html b/website/web/templates/misp_push_view.html index 43969adc..a174c558 100644 --- a/website/web/templates/misp_push_view.html +++ b/website/web/templates/misp_push_view.html @@ -1,6 +1,24 @@ -
-

Default tags: {{', '.join(default_tags)}}

-
+{% if misp_instances_settings|length > 1 %} +
+ +
+
+ {%for name in misp_instances_settings %} +
+
+{%endif%} + +
+{%for name, misp_settings in misp_instances_settings.items() %} +
+ + + + +
@@ -12,20 +30,21 @@
- +
- {% if existing_event %} -

There is already an event on your MISP instance with this lookyloo capture.

+ {% if misp_settings.existing_event %} +

There is already an event on your MISP instance with this lookyloo capture.

- +
{% endif %} @@ -35,6 +54,9 @@
{% endif %} - +
+{%endfor%} +
diff --git a/website/web/templates/tree.html b/website/web/templates/tree.html index 71852074..c572d1f7 100644 --- a/website/web/templates/tree.html +++ b/website/web/templates/tree.html @@ -94,14 +94,26 @@ $('#mispPushModal').on('show.bs.modal', function(e) { var button = $(e.relatedTarget); var modal = $(this); - modal.find('.modal-body').load(button.data("remote")); + modal.find('.modal-body').load(button.data("remote"), function(result){ + $('#mispSelector button').on('click', function(e){ + var thisBtn = $(this); + thisBtn.addClass('active').siblings().removeClass('active'); + $('#'+thisBtn.val()).show().siblings().hide() + }); + }); });