From 39381305d839a77fd2b1eb82da21b787e4fa76b9 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Tue, 29 Aug 2017 00:05:59 +0200 Subject: [PATCH 01/18] sigmac: Generic Text File Output Moved output logic into generic class. --- tools/backends.py | 38 +++++++++++++++++++++++++++++++++----- tools/sigmac.py | 16 ++++++---------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/tools/backends.py b/tools/backends.py index a84caf402..46b4af064 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -1,5 +1,6 @@ # Output backends for sigmac +import sys import json import re import sigma @@ -17,25 +18,50 @@ def getBackend(name): except KeyError as e: raise LookupError("Backend not found") from e -### Generic base classes +### Output classes +class OutputSingle: + """ + Single file output + By default, this opens the given file or stdin and passes everything into this. + """ + def __init__(self, filename=None): + if type(filename) == str: + self.fd = open(filename, "w") + else: + self.fd = sys.stdout + + def print(self, *args, **kwargs): + print(*args, file=self.fd, **kwargs) + + def close(self): + self.fd.close() + +### Generic backend base classes class BaseBackend: """Base class for all backends""" identifier = "base" active = False - index_field = None # field name that is used to address indices + index_field = None # field name that is used to address indices + output_class = None # one of the above output classes + file_list = None - def __init__(self, sigmaconfig): + def __init__(self, sigmaconfig, filename=None): + """ + Initialize backend. This gets a sigmaconfig object, which is notified about the used backend class by + passing the object instance to it. Further, output files are initialized by the output class defined in output_class. + """ if not isinstance(sigmaconfig, (sigma.SigmaConfiguration, None)): raise TypeError("SigmaConfiguration object expected") self.sigmaconfig = sigmaconfig self.sigmaconfig.set_backend(self) + self.output = self.output_class(filename) def generate(self, parsed): result = self.generateNode(parsed.parsedSearch) if parsed.parsedAgg: result += self.generateAggregation(parsed.parsedAgg) - return result + self.output.print(result) def generateNode(self, node): if type(node) == sigma.ConditionAND: @@ -83,6 +109,7 @@ class SingleTextQueryBackend(BaseBackend): """Base class for backends that generate one text-based expression from a Sigma rule""" identifier = "base-textquery" active = False + output_class = OutputSingle # the following class variables define the generation and behavior of queries from a parse tree some are prefilled with default values that are quite usual reEscape = None # match characters that must be quoted @@ -233,9 +260,10 @@ class FieldnameListBackend(BaseBackend): """List all fieldnames from given Sigma rules for creation of a field mapping configuration.""" identifier = "fieldlist" active = True + output_class = OutputSingle def generate(self, parsed): - return "\n".join(sorted(set(list(flatten(self.generateNode(parsed.parsedSearch)))))) + self.output.print("\n".join(sorted(set(list(flatten(self.generateNode(parsed.parsedSearch))))))) def generateANDNode(self, node): return [self.generateNode(val) for val in node] diff --git a/tools/sigmac.py b/tools/sigmac.py index bd8e45316..6a1e99e2a 100755 --- a/tools/sigmac.py +++ b/tools/sigmac.py @@ -36,7 +36,7 @@ argparser.add_argument("--recurse", "-r", action="store_true", help="Recurse int argparser.add_argument("--target", "-t", default="es-qs", choices=backends.getBackendDict().keys(), help="Output target format") argparser.add_argument("--target-list", "-l", action="store_true", help="List available output target formats") argparser.add_argument("--config", "-c", help="Configuration with field name and index mapping for target environment (not yet implemented)") -argparser.add_argument("--output", "-o", help="Output file or filename prefix if multiple files are generated (not yet implemented)") +argparser.add_argument("--output", "-o", default=None, help="Output file or filename prefix if multiple files are generated (not yet implemented)") argparser.add_argument("--defer-abort", "-d", action="store_true", help="Don't abort on parse or conversion errors, proceed with next rule. The exit code from the last error is returned") argparser.add_argument("--ignore-not-implemented", "-I", action="store_true", help="Only return error codes for parse errors and ignore errors for rules with not implemented features") argparser.add_argument("--verbose", "-v", action="store_true", help="Be verbose") @@ -50,13 +50,6 @@ if cmdargs.target_list: sys.exit(0) out = sys.stdout -if cmdargs.output: - try: - out = open(cmdargs.output, mode='w') - except IOError: - print("Failed to open output file '%s': %s" % (cmdargs.output, str(e)), file=sys.stderr) - exit(1) - sigmaconfig = SigmaConfiguration() if cmdargs.config: try: @@ -71,10 +64,13 @@ if cmdargs.config: print("Sigma configuration parse error in %s: %s" % (conffile, str(e)), file=sys.stderr) try: - backend = backends.getBackend(cmdargs.target)(sigmaconfig) + backend = backends.getBackend(cmdargs.target)(sigmaconfig, cmdargs.output) except LookupError as e: print("Backend not found!", file=sys.stderr) sys.exit(2) +except IOError: + print("Failed to open output file '%s': %s" % (cmdargs.output, str(e)), file=sys.stderr) + exit(1) error = 0 for sigmafile in get_inputs(cmdargs.inputs, cmdargs.recurse): @@ -88,7 +84,7 @@ for sigmafile in get_inputs(cmdargs.inputs, cmdargs.recurse): print_debug("Condition Tokens:", condtoken) for condparsed in parser.condparsed: print_debug("Condition Parse Tree:", condparsed) - print(backend.generate(condparsed), file=out) + backend.generate(condparsed) except OSError as e: print("Failed to open Sigma file %s: %s" % (sigmafile, str(e)), file=sys.stderr) error = 5 From c5fc74f4408ad5abb4ecb9610847708a1b1c31a2 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Mon, 4 Sep 2017 00:56:04 +0200 Subject: [PATCH 02/18] Further backend changes * backends get complete SigmaParser objects instead of condition * addition of finalize step for backends * Renaming of output classes --- tools/backends.py | 85 +++++++++++++++++++++++++++++++++++++++++------ tools/sigma.py | 3 +- tools/sigmac.py | 4 +-- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/tools/backends.py b/tools/backends.py index 46b4af064..5d3c4fa28 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -19,7 +19,7 @@ def getBackend(name): raise LookupError("Backend not found") from e ### Output classes -class OutputSingle: +class SingleOutput: """ Single file output @@ -37,6 +37,61 @@ class OutputSingle: def close(self): self.fd.close() +class MultiOutput: + """ + Multiple file output + + Prepares multiple SingleOutput instances with basename + suffix as file names, on for each suffix. + The switch() method is used to switch between these outputs. + + This class must be inherited and suffixes must be a dict as follows: file id -> suffix + """ + suffixes = None + + def __init__(self, basename): + """Initializes all outputs with basename and corresponding suffix as SingleOutput object.""" + if suffixes == None: + raise NotImplementedError("OutputMulti must be derived, at least suffixes must be set") + if type(basename) != str: + raise TypeError("OutputMulti constructor basename parameter must be string") + + self.outputs = dict() + self.output = None + for name, suffix in self.suffixes: + self.outputs[name] = SingleOutput(basename + suffix) + + def select(self, name): + """Select an output as current output""" + self.output = self.outputs[name] + + def print(self, *args, **kwargs): + self.output.print(*args, **kwargs) + + def close(self): + for out in self.outputs: + out.close() + +class StringOutput(SingleOutput): + """Collect input silently and return resulting string.""" + def __init__(self, filename=None): + self.out = "" + + def print(self, *args, **kwargs): + try: + del kwargs['file'] + except KeyError: + pass + print(*args, file=self, **kwargs) + + def write(self, s): + self.out += s + + def result(self): + return self.out + + def close(self): + pass + ### Generic backend base classes class BaseBackend: """Base class for all backends""" @@ -57,11 +112,13 @@ class BaseBackend: self.sigmaconfig.set_backend(self) self.output = self.output_class(filename) - def generate(self, parsed): - result = self.generateNode(parsed.parsedSearch) - if parsed.parsedAgg: - result += self.generateAggregation(parsed.parsedAgg) - self.output.print(result) + def generate(self, sigmaparser): + """Method is called for each sigma rule and receives the parsed rule (SigmaParser)""" + for parsed in sigmaparser.condparsed: + result = self.generateNode(parsed.parsedSearch) + if parsed.parsedAgg: + result += self.generateAggregation(parsed.parsedAgg) + self.output.print(result) def generateNode(self, node): if type(node) == sigma.ConditionAND: @@ -105,11 +162,18 @@ class BaseBackend: def generateAggregation(self, agg): raise NotImplementedError("Aggregations not implemented for this backend") + def finalize(self): + """ + Is called after the last file was processed with generate(). The right place if this backend is not intended to + look isolated at each rule, but generates an output which incorporates multiple rules, e.g. dashboards. + """ + pass + class SingleTextQueryBackend(BaseBackend): """Base class for backends that generate one text-based expression from a Sigma rule""" identifier = "base-textquery" active = False - output_class = OutputSingle + output_class = SingleOutput # the following class variables define the generation and behavior of queries from a parse tree some are prefilled with default values that are quite usual reEscape = None # match characters that must be quoted @@ -260,10 +324,11 @@ class FieldnameListBackend(BaseBackend): """List all fieldnames from given Sigma rules for creation of a field mapping configuration.""" identifier = "fieldlist" active = True - output_class = OutputSingle + output_class = SingleOutput - def generate(self, parsed): - self.output.print("\n".join(sorted(set(list(flatten(self.generateNode(parsed.parsedSearch))))))) + def generate(self, sigmaparser): + for parsed in sigmaparser.condparsed: + self.output.print("\n".join(sorted(set(list(flatten(self.generateNode(parsed.parsedSearch))))))) def generateANDNode(self, node): return [self.generateNode(val) for val in node] diff --git a/tools/sigma.py b/tools/sigma.py index 3fc67c5ff..64765f676 100644 --- a/tools/sigma.py +++ b/tools/sigma.py @@ -12,8 +12,9 @@ class SigmaParser: def __init__(self, sigma, config): self.definitions = dict() self.values = dict() - self.parsedyaml = yaml.safe_load(sigma) self.config = config + self.parsedyaml = yaml.safe_load(sigma) + self.parse_sigma() def parse_sigma(self): try: # definition uniqueness check diff --git a/tools/sigmac.py b/tools/sigmac.py index 6a1e99e2a..de8712334 100755 --- a/tools/sigmac.py +++ b/tools/sigmac.py @@ -79,12 +79,11 @@ for sigmafile in get_inputs(cmdargs.inputs, cmdargs.recurse): f = sigmafile.open() parser = SigmaParser(f, sigmaconfig) print_debug("Parsed YAML:\n", json.dumps(parser.parsedyaml, indent=2)) - parser.parse_sigma() for condtoken in parser.condtoken: print_debug("Condition Tokens:", condtoken) for condparsed in parser.condparsed: print_debug("Condition Parse Tree:", condparsed) - backend.generate(condparsed) + backend.generate(parser) except OSError as e: print("Failed to open Sigma file %s: %s" % (sigmafile, str(e)), file=sys.stderr) error = 5 @@ -116,5 +115,6 @@ for sigmafile in get_inputs(cmdargs.inputs, cmdargs.recurse): except: print_debug("Sigma rule didn't reached condition tokenization") print_debug() +backend.finalize() sys.exit(error) From be3c0cfb896d1652a6a0e4687b190e30ef057870 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Tue, 5 Sep 2017 00:14:13 +0200 Subject: [PATCH 03/18] sigmac: Kibana backend, first version * totally untested! * only supports searches * no visualizations/aggregation expressions * some fields are filled with default values (see code comments) --- tools/backends.py | 68 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/tools/backends.py b/tools/backends.py index 5d3c4fa28..8ca64ebc5 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -248,15 +248,67 @@ class ElasticsearchQuerystringBackend(SingleTextQueryBackend): mapExpression = "%s:%s" mapListsSpecialHandling = False -class ElasticsearchDSLBackend(BaseBackend): - """Converts Sigma rule into Elasticsearch DSL query (JSON).""" - identifier = "es-dsl" - active = False - -class KibanaBackend(ElasticsearchDSLBackend): - """Converts Sigma rule into Kibana JSON Configurations.""" +class KibanaBackend(ElasticsearchQuerystringBackend): + """Converts Sigma rule into Kibana JSON Configuration files (Searches, Visualizations, Dashboards).""" identifier = "kibana" - active = False + active = True + output_class = SingleOutput + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.kibanaconf = list() + self.searches = set() + + def generate(self, sigmaparser): + rulename = sigmaparser.parsedyaml["title"].replace(" ", "-") + for parsed in sigmaparser.condparsed: + result = self.generateNode(parsed.parsedSearch) + if rulename in self.searches: # add counter if name collides + cnt = 0 + while "%s-%d" % (rulename, cnt) in self.searches: + cnt += 1 + rulename = "%s-%d" % (rulename, cnt) + self.searches.add(rulename) + + try: + description = sigmaparser.parsedyaml["description"] + except KeyError: + description = "" + self.kibanaconf.append({ + "_id": rulename, + "_type": "search", + "_source": { + "title": sigmaparser.parsedyaml["title"], + "description": description, + "hits": 0, + "columns": [], # TODO: add columns used in search + "sort": ["@timestamp", "desc"], + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": json.dumps({ + "index": "logstash-*", # TODO: index from rule + "filter": [], + "highlight": { + "pre_tags": ["@kibana-highlighted-field@"], + "post_tags": ["@/kibana-highlighted-field@"], + "fields": { "*":{} }, + "require_field_match": False, + "fragment_size": 2147483647 + }, + "query": { + "query_string": { + "query": result, + "analyze_wildcard": True + } + } + } + ) + } + } + }) + + def finalize(self): + self.output.print(json.dumps(self.kibanaconf)) class LogPointBackend(SingleTextQueryBackend): """Converts Sigma rule into LogPoint query""" From 77a3e7ed91b947deb1c07b975171fc89b876002c Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Mon, 11 Sep 2017 00:27:14 +0200 Subject: [PATCH 04/18] Code cleanup --- tools/sigma.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/sigma.py b/tools/sigma.py index 64765f676..814fe6089 100644 --- a/tools/sigma.py +++ b/tools/sigma.py @@ -681,7 +681,6 @@ class ConditionalFieldMapping(SimpleFieldMapping): rulefieldvalues = sigmaparser.values[condfield] for condvalue in self.conditions[condfield]: if condvalue in rulefieldvalues: - print("found!") targets.update(self.conditions[condfield][condvalue]) if len(targets) == 0: # no matching condition, try with default mapping if self.default != None: @@ -870,7 +869,7 @@ class SigmaLogsourceConfiguration: """Match log source definition against given criteria, None = ignore""" searched = 0 for searchval, selfval in zip((category, product, service), (self.category, self.product, self.service)): - if searchval == None and selfval != None: # + if searchval == None and selfval != None: return False if searchval != None: searched += 1 From e5da26578d90268a161f9ee4b853231ee2eed416 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Mon, 11 Sep 2017 00:30:01 +0200 Subject: [PATCH 05/18] sigmac/kibana backend: index names from configuration --- tools/backends.py | 69 ++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/tools/backends.py b/tools/backends.py index 8ca64ebc5..22d247667 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -274,41 +274,50 @@ class KibanaBackend(ElasticsearchQuerystringBackend): description = sigmaparser.parsedyaml["description"] except KeyError: description = "" - self.kibanaconf.append({ - "_id": rulename, - "_type": "search", - "_source": { - "title": sigmaparser.parsedyaml["title"], - "description": description, - "hits": 0, - "columns": [], # TODO: add columns used in search - "sort": ["@timestamp", "desc"], - "version": 1, - "kibanaSavedObjectMeta": { - "searchSourceJSON": json.dumps({ - "index": "logstash-*", # TODO: index from rule - "filter": [], - "highlight": { - "pre_tags": ["@kibana-highlighted-field@"], - "post_tags": ["@/kibana-highlighted-field@"], - "fields": { "*":{} }, - "require_field_match": False, - "fragment_size": 2147483647 - }, - "query": { - "query_string": { - "query": result, - "analyze_wildcard": True + + + indices = sigmaparser.get_logsource().index + for index in indices: + if len(indices) > 1: # add index names if rule must be replicated because of ambigiuous index patterns + rulename += "-" + indexname + title = "%s (%s)" % (sigmaparser.parsedyaml["title"], index) + else: + title = sigmaparser.parsedyaml["title"] + self.kibanaconf.append({ + "_id": rulename, + "_type": "search", + "_source": { + "title": title, + "description": description, + "hits": 0, + "columns": [], # TODO: add columns used in search + "sort": ["@timestamp", "desc"], + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": json.dumps({ + "index": index, + "filter": [], + "highlight": { + "pre_tags": ["@kibana-highlighted-field@"], + "post_tags": ["@/kibana-highlighted-field@"], + "fields": { "*":{} }, + "require_field_match": False, + "fragment_size": 2147483647 + }, + "query": { + "query_string": { + "query": result, + "analyze_wildcard": True + } } } - } - ) + ) + } } - } - }) + }) def finalize(self): - self.output.print(json.dumps(self.kibanaconf)) + self.output.print(json.dumps(self.kibanaconf, indent=2)) class LogPointBackend(SingleTextQueryBackend): """Converts Sigma rule into LogPoint query""" From 135e38933481ac5ecc3b956c83ceb074f28cfd76 Mon Sep 17 00:00:00 2001 From: devife Date: Fri, 15 Sep 2017 09:46:37 -0500 Subject: [PATCH 06/18] Created a X-Pack Watcher output. This is has only been tested slightly. --- tools/backends.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/tools/backends.py b/tools/backends.py index 8ca64ebc5..7deefd8ad 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -308,7 +308,99 @@ class KibanaBackend(ElasticsearchQuerystringBackend): }) def finalize(self): - self.output.print(json.dumps(self.kibanaconf)) + self.output.print(self.kibanaconf) + +class XpackWatcher(ElasticsearchQuerystringBackend): + """Converts Sigma Rule into X-pack Watcher Json for alerting""" + identifier = "xpack-watcher" + active = True + output_class = SingleOutput + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.watcher_alert = dict() + self.searches = set() + + def generate(self, sigmaparser): + rulename = sigmaparser.parsedyaml["title"].replace(" ", "-") + for parsed in sigmaparser.condparsed: + result = self.generateNode(parsed.parsedSearch) + if rulename in self.searches: # add counter if name collides + cnt = 0 + while "%s-%d" % (rulename, cnt) in self.searches: + cnt += 1 + rulename = "%s-%d" % (rulename, cnt) + self.searches.add(rulename) + # get the details if this alert occurs + try: + description = sigmaparser.parsedyaml["description"] + except KeyError: + description = "" + try: + false_positives = sigmaparser.parsedyaml["falsepositives"] + except KeyError: + false_positives = "" + try: + level = sigmaparser.parsedyaml["level"] + except KeyError: + level = "" + logging_result = "Rule description: "+str(description)+", false positives: "+str(false_positives)+", level: "+level + # Get time frame if exists + try: + interval = sigmaparser.parsedyaml["detection"]["timeframe"] + except KeyError: + interval = "30m" + # creating condition + try: + condition = sigmaparser.parsedyaml["detection"]["condition"] + if condition.find('>') != -1: + alert_condition = {"gt": int(condition[condition.find('>')+2:])} + else: + alert_condition = {"not_eq": 0} + except KeyError: + alert_condition = {"not_eq": 0} + + self.watcher_alert[rulename] = { + "trigger": { + "schedule": { + "interval": interval # how often the watcher should check + } + }, + "input": { + "search": { + "request": { + "body": { + "size": 0, + "query": { + "query_string": { + "query": result, # this is where the elasticsearch query syntax goes + "analyze_wildcard": True + } + } + }, + "indices": [ + "*" # put the index here + ] + } + } + }, + "condition": { + "compare": { + "ctx.payload.hits.total": alert_condition + } + }, + "actions": { + "logging-action": { + "logging": { + "text": logging_result + } + } + } + } + + def finalize(self): + for key, value in self.watcher_alert.items(): + self.output.print(key, ':', json.dumps(self.watcher_alert[key])) class LogPointBackend(SingleTextQueryBackend): """Converts Sigma rule into LogPoint query""" From 9bc8e12a4fbd6694bf4c4a7c058144e900f29dd3 Mon Sep 17 00:00:00 2001 From: devife Date: Fri, 15 Sep 2017 09:49:57 -0500 Subject: [PATCH 07/18] Created a X-Pack Watcher output. This is has only been tested slightly. --- tools/backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/backends.py b/tools/backends.py index 7deefd8ad..4275678b1 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -308,7 +308,7 @@ class KibanaBackend(ElasticsearchQuerystringBackend): }) def finalize(self): - self.output.print(self.kibanaconf) + self.output.print(json.dumps(self.kibanaconf)) class XpackWatcher(ElasticsearchQuerystringBackend): """Converts Sigma Rule into X-pack Watcher Json for alerting""" From d3201229b0cbc5455e3fa0f030027ea6f49167ec Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sat, 16 Sep 2017 00:32:31 +0200 Subject: [PATCH 08/18] sigmac: Fixed matching of log sources between rules and configuration --- tools/sigma.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/sigma.py b/tools/sigma.py index 814fe6089..c10b5665e 100644 --- a/tools/sigma.py +++ b/tools/sigma.py @@ -871,7 +871,7 @@ class SigmaLogsourceConfiguration: for searchval, selfval in zip((category, product, service), (self.category, self.product, self.service)): if searchval == None and selfval != None: return False - if searchval != None: + if selfval != None: searched += 1 if searchval != selfval: return False From c8a66e48b6e701edef831bf00b5c32aaa6a3f5ea Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sat, 16 Sep 2017 00:37:16 +0200 Subject: [PATCH 09/18] sigmac: improved Kibana backend * added fields from rules * default index if none is matching --- tools/backends.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tools/backends.py b/tools/backends.py index 22d247667..d08ee8daa 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -274,9 +274,23 @@ class KibanaBackend(ElasticsearchQuerystringBackend): description = sigmaparser.parsedyaml["description"] except KeyError: description = "" - + + columns = list() + try: + for field in sigmaparser.parsedyaml["fields"]: + mapped = sigmaparser.config.get_fieldmapping(field).resolve_fieldname(field) + if type(mapped) == str: + columns.append(mapped) + elif type(mapped) == list: + columns.extend(mapped) + else: + raise TypeError("Field mapping must return string or list") + except KeyError: # no 'fields' attribute + pass indices = sigmaparser.get_logsource().index + if len(indices) == 0: + indices = ["logstash-*"] for index in indices: if len(indices) > 1: # add index names if rule must be replicated because of ambigiuous index patterns rulename += "-" + indexname @@ -290,7 +304,7 @@ class KibanaBackend(ElasticsearchQuerystringBackend): "title": title, "description": description, "hits": 0, - "columns": [], # TODO: add columns used in search + "columns": columns, "sort": ["@timestamp", "desc"], "version": 1, "kibanaSavedObjectMeta": { From 270ab9ba782057450dd35fe8176d935171b66844 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sat, 16 Sep 2017 23:46:40 +0200 Subject: [PATCH 10/18] Added backend options * generic support for backend-specific options * kibana backend option for title prefix --- Makefile | 1 + tools/backends.py | 27 ++++++++++++++++++++++++++- tools/sigmac.py | 5 ++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index dc5b7a5a0..2b33aab97 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ test-yaml: test-sigmac: tools/sigmac.py -l tools/sigmac.py -rvdI -t es-qs rules/ + tools/sigmac.py -rvdI -t kibana rules/ tools/sigmac.py -rvdI -t splunk rules/ tools/sigmac.py -rvdI -t logpoint rules/ tools/sigmac.py -rvdI -t fieldlist rules/ diff --git a/tools/backends.py b/tools/backends.py index d08ee8daa..7e0717f74 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -18,6 +18,25 @@ def getBackend(name): except KeyError as e: raise LookupError("Backend not found") from e +class BackendOptions(dict): + """Object contains all options that should be passed to the backend from command line (or other user interfaces)""" + + def __init__(self, options): + """ + Receives the argparser result from the backend option paramater value list (nargs=*) and builds the dict from it. There are two option types: + + * key=value: self{key} = value + * key: self{key} = True + """ + if options == None: + return + for option in options: + parsed = option.split("=", 1) + try: + self[parsed[0]] = parsed[1] + except IndexError: + self[parsed[0]] = True + ### Output classes class SingleOutput: """ @@ -101,13 +120,14 @@ class BaseBackend: output_class = None # one of the above output classes file_list = None - def __init__(self, sigmaconfig, filename=None): + def __init__(self, sigmaconfig, backend_options=None, filename=None): """ Initialize backend. This gets a sigmaconfig object, which is notified about the used backend class by passing the object instance to it. Further, output files are initialized by the output class defined in output_class. """ if not isinstance(sigmaconfig, (sigma.SigmaConfiguration, None)): raise TypeError("SigmaConfiguration object expected") + self.options = backend_options self.sigmaconfig = sigmaconfig self.sigmaconfig.set_backend(self) self.output = self.output_class(filename) @@ -297,6 +317,11 @@ class KibanaBackend(ElasticsearchQuerystringBackend): title = "%s (%s)" % (sigmaparser.parsedyaml["title"], index) else: title = sigmaparser.parsedyaml["title"] + try: + title = self.options["prefix"] + title + except KeyError: + pass + self.kibanaconf.append({ "_id": rulename, "_type": "search", diff --git a/tools/sigmac.py b/tools/sigmac.py index de8712334..1965ed770 100755 --- a/tools/sigmac.py +++ b/tools/sigmac.py @@ -37,6 +37,7 @@ argparser.add_argument("--target", "-t", default="es-qs", choices=backends.getBa argparser.add_argument("--target-list", "-l", action="store_true", help="List available output target formats") argparser.add_argument("--config", "-c", help="Configuration with field name and index mapping for target environment (not yet implemented)") argparser.add_argument("--output", "-o", default=None, help="Output file or filename prefix if multiple files are generated (not yet implemented)") +argparser.add_argument("--backend-option", "-O", nargs="*", help="Options and switches that are passed to the backend") argparser.add_argument("--defer-abort", "-d", action="store_true", help="Don't abort on parse or conversion errors, proceed with next rule. The exit code from the last error is returned") argparser.add_argument("--ignore-not-implemented", "-I", action="store_true", help="Only return error codes for parse errors and ignore errors for rules with not implemented features") argparser.add_argument("--verbose", "-v", action="store_true", help="Be verbose") @@ -63,8 +64,10 @@ if cmdargs.config: except SigmaParseError as e: print("Sigma configuration parse error in %s: %s" % (conffile, str(e)), file=sys.stderr) +backend_options = backends.BackendOptions(cmdargs.backend_option) + try: - backend = backends.getBackend(cmdargs.target)(sigmaconfig, cmdargs.output) + backend = backends.getBackend(cmdargs.target)(sigmaconfig, backend_options, cmdargs.output) except LookupError as e: print("Backend not found!", file=sys.stderr) sys.exit(2) From 6b8a5aea4afeb43edfe7e6791e36e3dc2e0a3861 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sun, 17 Sep 2017 00:20:17 +0200 Subject: [PATCH 11/18] Added vhost field to web rules --- rules/web/web_multiple_suspicious_resp_codes_single_source.yml | 1 + rules/web/web_webshell_keyword.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/rules/web/web_multiple_suspicious_resp_codes_single_source.yml b/rules/web/web_multiple_suspicious_resp_codes_single_source.yml index b2993d94a..935b406ae 100644 --- a/rules/web/web_multiple_suspicious_resp_codes_single_source.yml +++ b/rules/web/web_multiple_suspicious_resp_codes_single_source.yml @@ -14,6 +14,7 @@ detection: condition: selection | count() by clientip > 10 fields: - client_ip + - vhost - url - response falsepositives: diff --git a/rules/web/web_webshell_keyword.yml b/rules/web/web_webshell_keyword.yml index b2d8988ac..f6b453cc6 100644 --- a/rules/web/web_webshell_keyword.yml +++ b/rules/web/web_webshell_keyword.yml @@ -11,6 +11,7 @@ detection: condition: keywords fields: - client_ip + - vhost - url - response falsepositives: From a18b8eca525d5f8e4a0eb6cc8b797137c823f120 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sun, 17 Sep 2017 00:31:25 +0200 Subject: [PATCH 12/18] sigmac: changed backend description for kibana backend --- tools/backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/backends.py b/tools/backends.py index 7e0717f74..589748463 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -269,7 +269,7 @@ class ElasticsearchQuerystringBackend(SingleTextQueryBackend): mapListsSpecialHandling = False class KibanaBackend(ElasticsearchQuerystringBackend): - """Converts Sigma rule into Kibana JSON Configuration files (Searches, Visualizations, Dashboards).""" + """Converts Sigma rule into Kibana JSON Configuration files (searches only).""" identifier = "kibana" active = True output_class = SingleOutput From 9b65f250a8bda94cf120d4198e4bb54ac1df1fd1 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sun, 17 Sep 2017 00:32:57 +0200 Subject: [PATCH 13/18] Renamed rule file (typo) --- ...rk_django_exceptins.yml => appframework_django_exceptions.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rules/application/{appframework_django_exceptins.yml => appframework_django_exceptions.yml} (100%) diff --git a/rules/application/appframework_django_exceptins.yml b/rules/application/appframework_django_exceptions.yml similarity index 100% rename from rules/application/appframework_django_exceptins.yml rename to rules/application/appframework_django_exceptions.yml From 545e05370f77fcb8170728965ac686109bc439ea Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sun, 17 Sep 2017 00:36:04 +0200 Subject: [PATCH 14/18] Added first config for logstash-linux project URL: https://github.com/thomaspatzke/logstash-linux --- tools/config/elk-linux.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tools/config/elk-linux.yml diff --git a/tools/config/elk-linux.yml b/tools/config/elk-linux.yml new file mode 100644 index 000000000..9a32c2706 --- /dev/null +++ b/tools/config/elk-linux.yml @@ -0,0 +1,14 @@ +logsources: + apache: + category: webserver + index: logstash-apache-* + webapp-error: + category: application + index: logstash-apache_error-* + linux-auth: + product: linux + service: auth + index: logstash-auth-* +fieldmappings: + client_ip: clientip + url: request From d410adb3975bafc73f5e48f4107fe50d7ddddf5f Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Fri, 22 Sep 2017 00:28:35 +0200 Subject: [PATCH 15/18] sigmac: X-Pack Watcher backend improvements * Renamed backend class according to convention * Output types: curl (default) and plain * Prefix of rule names * Indices from configuration * Support for multiple conditions per rule * Usage of parsed condition * Support for all condition operators * Fixed bug preventing from passing multiple options to backend * Added to CI tests --- Makefile | 3 +- tools/backends.py | 119 ++++++++++++++++++++++++++++------------------ tools/sigmac.py | 2 +- 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/Makefile b/Makefile index 2b33aab97..98d5807e2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test test-yaml test-sigmac + .PHONY: test test-yaml test-sigmac test: test-yaml test-sigmac test-yaml: @@ -8,6 +8,7 @@ test-sigmac: tools/sigmac.py -l tools/sigmac.py -rvdI -t es-qs rules/ tools/sigmac.py -rvdI -t kibana rules/ + tools/sigmac.py -rvdI -t xpack-watcher rules/ tools/sigmac.py -rvdI -t splunk rules/ tools/sigmac.py -rvdI -t logpoint rules/ tools/sigmac.py -rvdI -t fieldlist rules/ diff --git a/tools/backends.py b/tools/backends.py index 54ec36f74..3d125dbdd 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -358,8 +358,8 @@ class KibanaBackend(ElasticsearchQuerystringBackend): def finalize(self): self.output.print(json.dumps(self.kibanaconf, indent=2)) -class XpackWatcher(ElasticsearchQuerystringBackend): - """Converts Sigma Rule into X-pack Watcher Json for alerting""" +class XPackWatcherBackend(ElasticsearchQuerystringBackend): + """Converts Sigma Rule into X-Pack Watcher JSON for alerting""" identifier = "xpack-watcher" active = True output_class = SingleOutput @@ -368,17 +368,31 @@ class XpackWatcher(ElasticsearchQuerystringBackend): super().__init__(*args, **kwargs) self.watcher_alert = dict() self.searches = set() + try: + self.output_type = self.options["output"] + except KeyError: + self.output_type = "curl" + + try: + self.es = self.options["es"] + except KeyError: + self.es = "localhost:9200" def generate(self, sigmaparser): rulename = sigmaparser.parsedyaml["title"].replace(" ", "-") for parsed in sigmaparser.condparsed: result = self.generateNode(parsed.parsedSearch) + try: # add prefix if available + rulename = self.options["prefix"] + rulename + except KeyError: + pass if rulename in self.searches: # add counter if name collides cnt = 0 while "%s-%d" % (rulename, cnt) in self.searches: cnt += 1 rulename = "%s-%d" % (rulename, cnt) self.searches.add(rulename) + # get the details if this alert occurs try: description = sigmaparser.parsedyaml["description"] @@ -399,56 +413,71 @@ class XpackWatcher(ElasticsearchQuerystringBackend): except KeyError: interval = "30m" # creating condition - try: - condition = sigmaparser.parsedyaml["detection"]["condition"] - if condition.find('>') != -1: - alert_condition = {"gt": int(condition[condition.find('>')+2:])} - else: + for condition in sigmaparser.condparsed: + try: + if condition.parsedAgg.cond_op == ">": + alert_condition = { "gt": int(condition.parsedAgg.condition) } + elif condition.parsedAgg.cond_op == ">=": + alert_condition = { "gte": int(condition.parsedAgg.condition) } + elif condition.parsedAgg.cond_op == "<": + alert_condition = { "lt": int(condition.parsedAgg.condition) } + elif condition.parsedAgg.cond_op == "<=": + alert_condition = { "lte": int(condition.parsedAgg.condition) } + else: + alert_condition = {"not_eq": 0} + except KeyError: + alert_condition = {"not_eq": 0} + except AttributeError: alert_condition = {"not_eq": 0} - except KeyError: - alert_condition = {"not_eq": 0} - self.watcher_alert[rulename] = { - "trigger": { - "schedule": { - "interval": interval # how often the watcher should check - } - }, - "input": { - "search": { - "request": { - "body": { - "size": 0, - "query": { - "query_string": { - "query": result, # this is where the elasticsearch query syntax goes - "analyze_wildcard": True - } + indices = sigmaparser.get_logsource().index + if len(indices) == 0: + indices = ["logstash-*"] + + self.watcher_alert[rulename] = { + "trigger": { + "schedule": { + "interval": interval # how often the watcher should check + } + }, + "input": { + "search": { + "request": { + "body": { + "size": 0, + "query": { + "query_string": { + "query": result, # this is where the elasticsearch query syntax goes + "analyze_wildcard": True + } + } + }, + "indices": indices } - }, - "indices": [ - "*" # put the index here - ] + } + }, + "condition": { + "compare": { # TODO: Issue #49 + "ctx.payload.hits.total": alert_condition + } + }, + "actions": { + "logging-action": { + "logging": { + "text": logging_result + } + } } } - }, - "condition": { - "compare": { - "ctx.payload.hits.total": alert_condition - } - }, - "actions": { - "logging-action": { - "logging": { - "text": logging_result - } - } - } - } def finalize(self): - for key, value in self.watcher_alert.items(): - self.output.print(key, ':', json.dumps(self.watcher_alert[key])) + for rulename, rule in self.watcher_alert.items(): + if self.output_type == "plain": # output request line + body + self.output.print("PUT _xpack/watcher/watch/%s\n%s\n" % (rulename, json.dumps(rule, indent=2))) + elif self.output_type == "curl": # output curl command line + self.output.print("curl -s -XPUT --data-binary @- %s/_xpack/watcher/watch/%s < Date: Sat, 30 Sep 2017 01:03:08 +0200 Subject: [PATCH 16/18] sigmac: MultiRuleOutputMixin * Moved rule name generation into mixin * KibanaBackend and XPackWatcherBackend now use this mixin instead of doing the same thing in both classes. --- tools/backends.py | 55 +++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/tools/backends.py b/tools/backends.py index 3d125dbdd..b38721aee 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -111,7 +111,7 @@ class StringOutput(SingleOutput): def close(self): pass -### Generic backend base classes +### Generic backend base classes and mixins class BaseBackend: """Base class for all backends""" identifier = "base" @@ -125,6 +125,7 @@ class BaseBackend: Initialize backend. This gets a sigmaconfig object, which is notified about the used backend class by passing the object instance to it. Further, output files are initialized by the output class defined in output_class. """ + super().__init__() if not isinstance(sigmaconfig, (sigma.SigmaConfiguration, None)): raise TypeError("SigmaConfiguration object expected") self.options = backend_options @@ -249,6 +250,32 @@ class SingleTextQueryBackend(BaseBackend): def generateValueNode(self, node): return self.valueExpression % (self.cleanValue(str(node))) +class MultiRuleOutputMixin: + """Mixin with common for multi-rule outputs""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.rulenames = set() + + def getRuleName(self, sigmaparser): + """ + Generate a rule name from the title of the Sigma rule with following properties: + + * Spaces are replaced with - + * Unique name by addition of a counter if generated name already in usage + + Generated names are tracked by the Mixin. + + """ + rulename = sigmaparser.parsedyaml["title"].replace(" ", "-") + if rulename in self.rulenames: # add counter if name collides + cnt = 2 + while "%s-%d" % (rulename, cnt) in self.rulenames: + cnt += 1 + rulename = "%s-%d" % (rulename, cnt) + self.rulenames.add(rulename) + + return rulename + ### Backends for specific SIEMs class ElasticsearchQuerystringBackend(SingleTextQueryBackend): @@ -268,7 +295,7 @@ class ElasticsearchQuerystringBackend(SingleTextQueryBackend): mapExpression = "%s:%s" mapListsSpecialHandling = False -class KibanaBackend(ElasticsearchQuerystringBackend): +class KibanaBackend(ElasticsearchQuerystringBackend, MultiRuleOutputMixin): """Converts Sigma rule into Kibana JSON Configuration files (searches only).""" identifier = "kibana" active = True @@ -277,18 +304,11 @@ class KibanaBackend(ElasticsearchQuerystringBackend): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.kibanaconf = list() - self.searches = set() def generate(self, sigmaparser): - rulename = sigmaparser.parsedyaml["title"].replace(" ", "-") for parsed in sigmaparser.condparsed: + rulename = self.getRuleName(sigmaparser) result = self.generateNode(parsed.parsedSearch) - if rulename in self.searches: # add counter if name collides - cnt = 0 - while "%s-%d" % (rulename, cnt) in self.searches: - cnt += 1 - rulename = "%s-%d" % (rulename, cnt) - self.searches.add(rulename) try: description = sigmaparser.parsedyaml["description"] @@ -358,7 +378,7 @@ class KibanaBackend(ElasticsearchQuerystringBackend): def finalize(self): self.output.print(json.dumps(self.kibanaconf, indent=2)) -class XPackWatcherBackend(ElasticsearchQuerystringBackend): +class XPackWatcherBackend(ElasticsearchQuerystringBackend, MultiRuleOutputMixin): """Converts Sigma Rule into X-Pack Watcher JSON for alerting""" identifier = "xpack-watcher" active = True @@ -367,7 +387,6 @@ class XPackWatcherBackend(ElasticsearchQuerystringBackend): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.watcher_alert = dict() - self.searches = set() try: self.output_type = self.options["output"] except KeyError: @@ -379,19 +398,9 @@ class XPackWatcherBackend(ElasticsearchQuerystringBackend): self.es = "localhost:9200" def generate(self, sigmaparser): - rulename = sigmaparser.parsedyaml["title"].replace(" ", "-") for parsed in sigmaparser.condparsed: + rulename = self.getRuleName(sigmaparser) result = self.generateNode(parsed.parsedSearch) - try: # add prefix if available - rulename = self.options["prefix"] + rulename - except KeyError: - pass - if rulename in self.searches: # add counter if name collides - cnt = 0 - while "%s-%d" % (rulename, cnt) in self.searches: - cnt += 1 - rulename = "%s-%d" % (rulename, cnt) - self.searches.add(rulename) # get the details if this alert occurs try: From b8eedfe3f03e744ac5721e6c43b6ba2b8a1f3240 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sat, 30 Sep 2017 23:22:05 +0200 Subject: [PATCH 17/18] Fixes and refactoring of KibanaBackend and XPackWatcherBackend * Moved unnecessary code out of condition loop * Index specific rule-name not appended to rulename variable used later from other rule/index. * Merged condition loop --- tools/backends.py | 82 ++++++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 48 deletions(-) diff --git a/tools/backends.py b/tools/backends.py index b38721aee..45943ec8e 100644 --- a/tools/backends.py +++ b/tools/backends.py @@ -306,34 +306,33 @@ class KibanaBackend(ElasticsearchQuerystringBackend, MultiRuleOutputMixin): self.kibanaconf = list() def generate(self, sigmaparser): + rulename = self.getRuleName(sigmaparser) + description = sigmaparser.parsedyaml.setdefault("description", "") + + columns = list() + try: + for field in sigmaparser.parsedyaml["fields"]: + mapped = sigmaparser.config.get_fieldmapping(field).resolve_fieldname(field) + if type(mapped) == str: + columns.append(mapped) + elif type(mapped) == list: + columns.extend(mapped) + else: + raise TypeError("Field mapping must return string or list") + except KeyError: # no 'fields' attribute + pass + + indices = sigmaparser.get_logsource().index + if len(indices) == 0: + indices = ["logstash-*"] + for parsed in sigmaparser.condparsed: - rulename = self.getRuleName(sigmaparser) result = self.generateNode(parsed.parsedSearch) - try: - description = sigmaparser.parsedyaml["description"] - except KeyError: - description = "" - - columns = list() - try: - for field in sigmaparser.parsedyaml["fields"]: - mapped = sigmaparser.config.get_fieldmapping(field).resolve_fieldname(field) - if type(mapped) == str: - columns.append(mapped) - elif type(mapped) == list: - columns.extend(mapped) - else: - raise TypeError("Field mapping must return string or list") - except KeyError: # no 'fields' attribute - pass - - indices = sigmaparser.get_logsource().index - if len(indices) == 0: - indices = ["logstash-*"] for index in indices: + final_rulename = rulename if len(indices) > 1: # add index names if rule must be replicated because of ambigiuous index patterns - rulename += "-" + indexname + final_rulename += "-" + indexname title = "%s (%s)" % (sigmaparser.parsedyaml["title"], index) else: title = sigmaparser.parsedyaml["title"] @@ -343,7 +342,7 @@ class KibanaBackend(ElasticsearchQuerystringBackend, MultiRuleOutputMixin): pass self.kibanaconf.append({ - "_id": rulename, + "_id": final_rulename, "_type": "search", "_source": { "title": title, @@ -398,31 +397,22 @@ class XPackWatcherBackend(ElasticsearchQuerystringBackend, MultiRuleOutputMixin) self.es = "localhost:9200" def generate(self, sigmaparser): - for parsed in sigmaparser.condparsed: - rulename = self.getRuleName(sigmaparser) - result = self.generateNode(parsed.parsedSearch) - # get the details if this alert occurs - try: - description = sigmaparser.parsedyaml["description"] - except KeyError: - description = "" - try: - false_positives = sigmaparser.parsedyaml["falsepositives"] - except KeyError: - false_positives = "" - try: - level = sigmaparser.parsedyaml["level"] - except KeyError: - level = "" + rulename = self.getRuleName(sigmaparser) + description = sigmaparser.parsedyaml.setdefault("description", "") + false_positives = sigmaparser.parsedyaml.setdefault("falsepositives", "") + level = sigmaparser.parsedyaml.setdefault("level", "") logging_result = "Rule description: "+str(description)+", false positives: "+str(false_positives)+", level: "+level # Get time frame if exists - try: - interval = sigmaparser.parsedyaml["detection"]["timeframe"] - except KeyError: - interval = "30m" + interval = sigmaparser.parsedyaml["detection"].setdefault("timeframe", "30m") + # creating condition + indices = sigmaparser.get_logsource().index + if len(indices) == 0: + indices = ["logstash-*"] + for condition in sigmaparser.condparsed: + result = self.generateNode(condition.parsedSearch) try: if condition.parsedAgg.cond_op == ">": alert_condition = { "gt": int(condition.parsedAgg.condition) } @@ -439,10 +429,6 @@ class XPackWatcherBackend(ElasticsearchQuerystringBackend, MultiRuleOutputMixin) except AttributeError: alert_condition = {"not_eq": 0} - indices = sigmaparser.get_logsource().index - if len(indices) == 0: - indices = ["logstash-*"] - self.watcher_alert[rulename] = { "trigger": { "schedule": { From f4720d5149743c533916c2cf7396a828fd13ae63 Mon Sep 17 00:00:00 2001 From: Florian Roth Date: Tue, 3 Oct 2017 12:47:38 +0200 Subject: [PATCH 18/18] APT17 malware UA https://twitter.com/cyb3rops/status/915135877709549568 --- rules/proxy/proxy_ua_apt.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/rules/proxy/proxy_ua_apt.yml b/rules/proxy/proxy_ua_apt.yml index c15d8253a..7c2f45317 100644 --- a/rules/proxy/proxy_ua_apt.yml +++ b/rules/proxy/proxy_ua_apt.yml @@ -24,6 +24,7 @@ detection: - 'Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-EN; rv:1.7.12) Gecko/20100719 Firefox/1.0.7' # Unit78020 Malware - 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.2.13) Firefox/3.6.13 GTB7.1' # Winnti related - 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)' # Winnti related + - 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1; WOW64; Trident/4.0; SLCC2; .NETCLR 2.0.50727)' # APT17 condition: selection fields: - ClientIP