diff --git a/CHANGELOG.md b/CHANGELOG.md index f27121a8b..0e5fbc183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - add function in capa/helpers to load plain and compressed JSON reports #1883 @Rohit1123 - document Antivirus warnings and VirusTotal false positive detections #2028 @RionEV @mr-tz +- render maec/* fields #843 @s-ff - replace Halo spinner with Rich #2086 @s-ff ### Breaking Changes diff --git a/capa/render/default.py b/capa/render/default.py index 2e5064740..e49a31e3c 100644 --- a/capa/render/default.py +++ b/capa/render/default.py @@ -102,7 +102,11 @@ def render_capabilities(doc: rd.ResultDocument, ostream: StringIO): if rows: ostream.write( - tabulate.tabulate(rows, headers=[width("Capability", 50), width("Namespace", 50)], tablefmt="mixed_outline") + tabulate.tabulate( + rows, + headers=[width("Capability", 50), width("Namespace", 50)], + tablefmt="mixed_outline", + ) ) ostream.write("\n") else: @@ -148,7 +152,55 @@ def render_attack(doc: rd.ResultDocument, ostream: StringIO): if rows: ostream.write( tabulate.tabulate( - rows, headers=[width("ATT&CK Tactic", 20), width("ATT&CK Technique", 80)], tablefmt="mixed_grid" + rows, + headers=[width("ATT&CK Tactic", 20), width("ATT&CK Technique", 80)], + tablefmt="mixed_grid", + ) + ) + ostream.write("\n") + + +def render_maec(doc: rd.ResultDocument, ostream: StringIO): + """ + example:: + + +--------------------------+-----------------------------------------------------------+ + | MAEC Category | MAEC Value | + |--------------------------+-----------------------------------------------------------| + | analysis-conclusion | malicious | + |--------------------------+-----------------------------------------------------------| + | malware-family | PlugX | + |--------------------------+-----------------------------------------------------------| + | malware-category | downloader | + | | launcher | + +--------------------------+-----------------------------------------------------------+ + """ + maec_categories = { + "analysis_conclusion", + "analysis_conclusion_ov", + "malware_family", + "malware_category", + "malware_category_ov", + } + maec_table = collections.defaultdict(set) + for rule in rutils.maec_rules(doc): + for maec_category in maec_categories: + maec_value = getattr(rule.meta.maec, maec_category, None) + if maec_value: + maec_table[maec_category].add(maec_value) + + rows = [] + for category in sorted(maec_categories): + values = maec_table.get(category, set()) + if values: + rows.append((rutils.bold(category.replace("_", "-")), "\n".join(sorted(values)))) + + if rows: + ostream.write( + tabulate.tabulate( + rows, + headers=[width("MAEC Category", 25), width("MAEC Value", 75)], + tablefmt="mixed_grid", ) ) ostream.write("\n") @@ -191,7 +243,9 @@ def render_mbc(doc: rd.ResultDocument, ostream: StringIO): if rows: ostream.write( tabulate.tabulate( - rows, headers=[width("MBC Objective", 25), width("MBC Behavior", 75)], tablefmt="mixed_grid" + rows, + headers=[width("MBC Objective", 25), width("MBC Behavior", 75)], + tablefmt="mixed_grid", ) ) ostream.write("\n") @@ -204,6 +258,8 @@ def render_default(doc: rd.ResultDocument): ostream.write("\n") render_attack(doc, ostream) ostream.write("\n") + render_maec(doc, ostream) + ostream.write("\n") render_mbc(doc, ostream) ostream.write("\n") render_capabilities(doc, ostream) diff --git a/capa/render/utils.py b/capa/render/utils.py index c292186e7..2846e05fd 100644 --- a/capa/render/utils.py +++ b/capa/render/utils.py @@ -7,7 +7,7 @@ # See the License for the specific language governing permissions and limitations under the License. import io -from typing import Union, Iterator +from typing import Dict, List, Tuple, Union, Iterator, Optional import termcolor @@ -40,9 +40,14 @@ def format_parts_id(data: Union[rd.AttackSpec, rd.MBCSpec]): return f"{'::'.join(data.parts)} [{data.id}]" +def sort_rules(rules: Dict[str, rd.RuleMatches]) -> List[Tuple[Optional[str], str, rd.RuleMatches]]: + """Sort rules by namespace and name.""" + return sorted((rule.meta.namespace or "", rule.meta.name, rule) for rule in rules.values()) + + def capability_rules(doc: rd.ResultDocument) -> Iterator[rd.RuleMatches]: """enumerate the rules in (namespace, name) order that are 'capability' rules (not lib/subscope/disposition/etc).""" - for _, _, rule in sorted((rule.meta.namespace or "", rule.meta.name, rule) for rule in doc.rules.values()): + for _, _, rule in sort_rules(doc.rules): if rule.meta.lib: continue if rule.meta.is_subscope_rule: @@ -61,6 +66,21 @@ def capability_rules(doc: rd.ResultDocument) -> Iterator[rd.RuleMatches]: yield rule +def maec_rules(doc: rd.ResultDocument) -> Iterator[rd.RuleMatches]: + """enumerate 'maec' rules.""" + for rule in doc.rules.values(): + if any( + [ + rule.meta.maec.analysis_conclusion, + rule.meta.maec.analysis_conclusion_ov, + rule.meta.maec.malware_family, + rule.meta.maec.malware_category, + rule.meta.maec.malware_category_ov, + ] + ): + yield rule + + class StringIO(io.StringIO): def writeln(self, s): self.write(s) diff --git a/tests/test_render.py b/tests/test_render.py index a4ef014d8..e56d013c5 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -6,6 +6,7 @@ # is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and limitations under the License. import textwrap +from unittest.mock import Mock import fixtures @@ -19,6 +20,7 @@ import capa.features.address import capa.features.basicblock import capa.render.result_document +import capa.render.result_document as rd import capa.features.freeze.features @@ -113,6 +115,54 @@ def test_render_meta_mbc(): assert capa.render.utils.format_parts_id(mbc) == canonical +def test_render_meta_maec(): + malware_family = "PlugX" + malware_category = "downloader" + analysis_conclusion = "malicious" + + rule_yaml = textwrap.dedent( + """ + rule: + meta: + name: test rule + scopes: + static: function + dynamic: process + authors: + - foo + maec/malware-family: {:s} + maec/malware-category: {:s} + maec/analysis-conclusion: {:s} + features: + - number: 1 + """.format( + malware_family, malware_category, analysis_conclusion + ) + ) + rule = capa.rules.Rule.from_yaml(rule_yaml) + rm = capa.render.result_document.RuleMatches( + meta=capa.render.result_document.RuleMetadata.from_capa(rule), + source=rule_yaml, + matches=(), + ) + + # create a mock ResultDocument + mock_rd = Mock(spec=rd.ResultDocument) + mock_rd.rules = {"test rule": rm} + + # capture the output of render_maec + output_stream = capa.render.utils.StringIO() + capa.render.default.render_maec(mock_rd, output_stream) + output = output_stream.getvalue() + + assert "analysis-conclusion" in output + assert analysis_conclusion in output + assert "malware-category" in output + assert malware_category in output + assert "malware-family" in output + assert malware_family in output + + @fixtures.parametrize( "feature,expected", [