diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ded3a5..fc146c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,7 +110,7 @@ It includes many new rules, including all new techniques introduced in MITRE ATT - linter: summarize results at the end #571 @williballenthin - meta: added `library_functions` field, `feature_counts.functions` does not include library functions any more #562 @mr-tz - linter: check for `or` with always true child statement, e.g. `optional`, colors #348 @mr-tz -- json: breaking change in results document; now contains parsed MBC fields instead of canonical representation #526 @mr-tz +- json: breaking change in results document; now contains parsed ATT&CK and MBC fields instead of canonical representation #526 @mr-tz - json: breaking change: record all matching strings for regex #159 @williballenthin - main: implement file limitations via rules not code #390 @williballenthin diff --git a/capa/render/__init__.py b/capa/render/__init__.py index bb199b50..eec22281 100644 --- a/capa/render/__init__.py +++ b/capa/render/__init__.py @@ -203,11 +203,44 @@ def convert_match_to_result_document(rules, capabilities, result): def convert_meta_to_result_document(meta): + attacks = meta.get("att&ck", []) + meta["att&ck"] = [parse_canonical_attack(attack) for attack in attacks] mbcs = meta.get("mbc", []) meta["mbc"] = [parse_canonical_mbc(mbc) for mbc in mbcs] return meta +def parse_canonical_attack(attck): + """ + parse capa's canonical ATT&CK representation: `Tactic::Technique::Subtechnique [Identifier]` + """ + id = "" + tactic = "" + technique = "" + subtechnique = "" + parts = attck.split("::") + if len(parts) > 0: + last = parts.pop() + last, _, id = last.rpartition(" ") + id = id.lstrip("[").rstrip("]") + parts.append(last) + + if len(parts) > 0: + tactic = parts[0] + if len(parts) > 1: + technique = parts[1] + if len(parts) > 2: + subtechnique = parts[2] + + return { + "parts": parts, + "id": id, + "tactic": tactic, + "technique": technique, + "subtechnique": subtechnique, + } + + def parse_canonical_mbc(mbc): """ parse capa's canonical MBC representation: `Objective::Behavior::Method [Identifier]` diff --git a/capa/render/default.py b/capa/render/default.py index 55727893..82f432d4 100644 --- a/capa/render/default.py +++ b/capa/render/default.py @@ -123,14 +123,10 @@ def render_attack(doc, ostream): continue for attack in rule["meta"]["att&ck"]: - tactic, _, rest = attack.partition("::") - if "::" in rest: - technique, _, rest = rest.partition("::") - subtechnique, _, id = rest.rpartition(" ") - tactics[tactic].add((technique, subtechnique, id)) + if attack.get("subtechnique"): + tactics[attack["tactic"]].add((attack["technique"], attack["subtechnique"], attack["id"])) else: - technique, _, id = rest.rpartition(" ") - tactics[tactic].add((technique, id)) + tactics[attack["tactic"]].add((attack["technique"], attack["id"])) rows = [] for tactic, techniques in sorted(tactics.items()): diff --git a/capa/render/utils.py b/capa/render/utils.py index 99e0405a..b9ae0c1b 100644 --- a/capa/render/utils.py +++ b/capa/render/utils.py @@ -29,8 +29,11 @@ def hex(n): return "0x%X" % n -def format_mbc(mbc): - return "%s [%s]" % ("::".join(mbc["parts"]), mbc["id"]) +def format_parts_id(data): + """ + format canonical representation of ATT&CK/MBC parts and ID + """ + return "%s [%s]" % ("::".join(data["parts"]), data["id"]) def capability_rules(doc): diff --git a/capa/render/vverbose.py b/capa/render/vverbose.py index b6a8fa8d..f14ed36c 100644 --- a/capa/render/vverbose.py +++ b/capa/render/vverbose.py @@ -219,8 +219,8 @@ def render_rules(ostream, doc): if not v: continue - if key == "mbc": - v = [rutils.format_mbc(mbc) for mbc in v] + if key in ("att&ck", "mbc"): + v = [rutils.format_parts_id(vv) for vv in v] if isinstance(v, list) and len(v) == 1: v = v[0] diff --git a/capa/rules.py b/capa/rules.py index bc05e97f..bf407439 100644 --- a/capa/rules.py +++ b/capa/rules.py @@ -579,8 +579,9 @@ class Rule(object): raise InvalidRule("{:s} is not a supported scope".format(scope)) meta = d["rule"]["meta"] - mbcs = meta.get("mbc", []) - if not isinstance(mbcs, list): + if not isinstance(meta.get("att&ck", []), list): + raise InvalidRule("ATT&CK mapping must be a list") + if not isinstance(meta.get("mbc", []), list): raise InvalidRule("MBC mapping must be a list") return cls(name, scope, build_statements(statements[0], scope), meta, definition) diff --git a/tests/test_main.py b/tests/test_main.py index 03f755c4..d38ead5b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -9,7 +9,6 @@ import json import textwrap -import pytest from fixtures import * import capa.main @@ -361,8 +360,8 @@ def test_not_render_rules_also_matched(z9324d_extractor, capsys): assert "create TCP socket" in std.out -# It tests main works with different backends def test_backend_option(capsys): + # tests that main works with different backends path = get_data_path_by_name("pma16-01") assert capa.main.main([path, "-j", "-b", capa.main.BACKEND_VIV]) == 0 std = capsys.readouterr() diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 00000000..10fda1e9 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,71 @@ +import textwrap + +import capa.rules +from capa.render import convert_meta_to_result_document +from capa.render.utils import format_parts_id + + +def test_render_meta_attack(): + # Persistence::Boot or Logon Autostart Execution::Registry Run Keys / Startup Folder [T1547.001] + id = "T1543.003" + tactic = "Persistence" + technique = "Create or Modify System Process" + subtechnique = "Windows Service" + canonical = "{:s}::{:s}::{:s} [{:s}]".format(tactic, technique, subtechnique, id) + + rule = textwrap.dedent( + """ + rule: + meta: + name: test rule + att&ck: + - {:s} + features: + - number: 1 + """.format( + canonical + ) + ) + r = capa.rules.Rule.from_yaml(rule) + rule_meta = convert_meta_to_result_document(r.meta) + attack = rule_meta["att&ck"][0] + + assert attack["id"] == id + assert attack["tactic"] == tactic + assert attack["technique"] == technique + assert attack["subtechnique"] == subtechnique + + assert format_parts_id(attack) == canonical + + +def test_render_meta_mbc(): + # Defense Evasion::Disable or Evade Security Tools::Heavens Gate [F0004.008] + id = "F0004.008" + objective = "Defense Evasion" + behavior = "Disable or Evade Security Tools" + method = "Heavens Gate" + canonical = "{:s}::{:s}::{:s} [{:s}]".format(objective, behavior, method, id) + + rule = textwrap.dedent( + """ + rule: + meta: + name: test rule + mbc: + - {:s} + features: + - number: 1 + """.format( + canonical + ) + ) + r = capa.rules.Rule.from_yaml(rule) + rule_meta = convert_meta_to_result_document(r.meta) + attack = rule_meta["mbc"][0] + + assert attack["id"] == id + assert attack["objective"] == objective + assert attack["behavior"] == behavior + assert attack["method"] == method + + assert format_parts_id(attack) == canonical diff --git a/tests/test_rules.py b/tests/test_rules.py index adab915b..33ed2d2a 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -399,6 +399,34 @@ def test_invalid_rules(): ) ) + # att&ck and mbc must be lists + with pytest.raises(capa.rules.InvalidRule): + r = capa.rules.Rule.from_yaml( + textwrap.dedent( + """ + rule: + meta: + name: test rule + att&ck: Tactic::Technique::Subtechnique [Identifier] + features: + - number: 1 + """ + ) + ) + with pytest.raises(capa.rules.InvalidRule): + r = capa.rules.Rule.from_yaml( + textwrap.dedent( + """ + rule: + meta: + name: test rule + mbc: Objective::Behavior::Method [Identifier] + features: + - number: 1 + """ + ) + ) + def test_number_symbol(): rule = textwrap.dedent(