Merge pull request #105 from fireeye/output-feature-count

Output feature count
This commit is contained in:
Willi Ballenthin
2020-07-06 14:09:47 -06:00
committed by GitHub
12 changed files with 432 additions and 63 deletions

View File

@@ -339,7 +339,13 @@ class CapaExplorerForm(idaapi.PluginForm):
rules_path = os.path.join(os.path.dirname(self.file_loc), "../..", "rules")
rules = capa.main.get_rules(rules_path)
rules = capa.rules.RuleSet(rules)
capabilities = capa.main.find_capabilities(rules, capa.features.extractors.ida.IdaFeatureExtractor(), True)
meta = capa.ida.helpers.collect_metadata()
capabilities, counts = capa.main.find_capabilities(
rules, capa.features.extractors.ida.IdaFeatureExtractor(), True
)
meta["analysis"].update(counts)
# support binary files specifically for x86/AMD64 shellcode
# warn user binary file is loaded but still allow capa to process it
@@ -364,7 +370,6 @@ class CapaExplorerForm(idaapi.PluginForm):
logger.info("analysis completed.")
meta = capa.ida.helpers.collect_metadata()
doc = capa.render.convert_capabilities_to_result_document(meta, rules, capabilities)
self.model_data.render_capa_doc(doc)

View File

@@ -68,7 +68,7 @@ def find_function_capabilities(ruleset, extractor, f):
function_features[capa.features.MatchedRule(rule_name)].add(va)
_, function_matches = capa.engine.match(ruleset.function_rules, function_features, oint(f))
return function_matches, bb_matches
return function_matches, bb_matches, len(function_features)
def find_file_capabilities(ruleset, extractor, function_features):
@@ -84,20 +84,25 @@ def find_file_capabilities(ruleset, extractor, function_features):
if feature not in file_features:
file_features[feature] = set()
logger.info("analyzed file and extracted %d features", len(file_features))
logger.debug("analyzed file and extracted %d features", len(file_features))
file_features.update(function_features)
_, matches = capa.engine.match(ruleset.file_rules, file_features, 0x0)
return matches
return matches, len(file_features)
def find_capabilities(ruleset, extractor, disable_progress=None):
all_function_matches = collections.defaultdict(list)
all_bb_matches = collections.defaultdict(list)
meta = {"feature_counts": {"file": 0, "functions": {},}}
for f in tqdm.tqdm(extractor.get_functions(), disable=disable_progress, unit=" functions"):
function_matches, bb_matches = find_function_capabilities(ruleset, extractor, f)
function_matches, bb_matches, feature_count = find_function_capabilities(ruleset, extractor, f)
meta["feature_counts"]["functions"][f.__int__()] = feature_count
logger.debug("analyzed function 0x%x and extracted %d features", f.__int__(), feature_count)
for rule_name, res in function_matches.items():
all_function_matches[rule_name].extend(res)
for rule_name, res in bb_matches.items():
@@ -110,14 +115,15 @@ def find_capabilities(ruleset, extractor, disable_progress=None):
for rule_name, results in all_function_matches.items()
}
all_file_matches = find_file_capabilities(ruleset, extractor, function_features)
all_file_matches, feature_count = find_file_capabilities(ruleset, extractor, function_features)
meta["feature_counts"]["file"] = feature_count
matches = {}
matches.update(all_bb_matches)
matches.update(all_function_matches)
matches.update(all_file_matches)
return matches
return matches, meta
def has_rule_with_namespace(rules, capabilities, rule_cat):
@@ -301,7 +307,10 @@ def get_rules(rule_path):
for root, dirs, files in os.walk(rule_path):
for file in files:
if not file.endswith(".yml"):
logger.warning("skipping non-.yml file: %s", file)
if not (file.endswith(".md") or file.endswith(".git")):
# expect to see readme.md, format.md, and maybe a .git directory
# other things maybe are rules, but are mis-named.
logger.warning("skipping non-.yml file: %s", file)
continue
rule_path = os.path.join(root, file)
@@ -485,7 +494,8 @@ def main(argv=None):
meta = collect_metadata(argv, args.sample, format, extractor)
capabilities = find_capabilities(rules, extractor)
capabilities, counts = find_capabilities(rules, extractor)
meta["analysis"].update(counts)
if has_file_limitation(rules, capabilities):
# bail if capa encountered file limitation e.g. a packed binary
@@ -542,12 +552,14 @@ def ida_main():
rules = get_rules(rules_path)
rules = capa.rules.RuleSet(rules)
capabilities = find_capabilities(rules, capa.features.extractors.ida.IdaFeatureExtractor())
meta = capa.ida.helpers.collect_metadata()
capabilities, counts = find_capabilities(rules, capa.features.extractors.ida.IdaFeatureExtractor())
meta["analysis"].update(counts)
if has_file_limitation(rules, capabilities, is_standalone=False):
capa.ida.helpers.inform_user_ida_ui("capa encountered warnings during analysis")
meta = capa.ida.helpers.collect_metadata()
print(capa.render.render_default(meta, rules, capabilities))

View File

@@ -17,11 +17,10 @@ def width(s, character_count):
def render_meta(doc, ostream):
rows = []
rows.append((width("md5", 22), width(doc["meta"]["sample"]["md5"], 82)))
rows.append(("path", doc["meta"]["sample"]["path"]))
rows.append(("timestamp", doc["meta"]["timestamp"]))
rows.append(("capa version", doc["meta"]["version"]))
rows = [
(width("md5", 22), width(doc["meta"]["sample"]["md5"], 82)),
("path", doc["meta"]["sample"]["path"]),
]
ostream.write(tabulate.tabulate(rows, tablefmt="psql"))
ostream.write("\n")
@@ -48,7 +47,7 @@ def render_capabilities(doc, ostream):
capability = "%s (%d matches)" % (rutils.bold(rule["meta"]["name"]), count)
rows.append((capability, rule["meta"]["namespace"]))
ostream.write(tabulate.tabulate(rows, headers=[width("CAPABILITY", 40), width("NAMESPACE", 40)], tablefmt="psql"))
ostream.write(tabulate.tabulate(rows, headers=[width("CAPABILITY", 50), width("NAMESPACE", 50)], tablefmt="psql"))
ostream.write("\n")

View File

@@ -20,22 +20,53 @@ import capa.rules
import capa.render.utils as rutils
def render_verbose(doc):
ostream = rutils.StringIO()
def render_meta(ostream, doc):
"""
like:
rows = []
rows.append(("md5", doc["meta"]["sample"]["md5"]))
rows.append(("sha1", doc["meta"]["sample"]["sha1"]))
rows.append(("sha256", doc["meta"]["sample"]["sha256"]))
rows.append(("path", doc["meta"]["sample"]["path"]))
rows.append(("timestamp", doc["meta"]["timestamp"]))
rows.append(("capa version", doc["meta"]["version"]))
rows.append(("format", doc["meta"]["analysis"]["format"]))
rows.append(("extractor", doc["meta"]["analysis"]["extractor"]))
rows.append(("base address", hex(doc["meta"]["analysis"]["base_address"])))
md5 84882c9d43e23d63b82004fae74ebb61
sha1 c6fb3b50d946bec6f391aefa4e54478cf8607211
sha256 5eced7367ed63354b4ed5c556e2363514293f614c2c2eb187273381b2ef5f0f9
path /tmp/suspicious.dll_
timestamp 2020-07-03T10:17:05.796933
capa version 0.0.0
format auto
extractor VivisectFeatureExtractor
base address 0x10000000
function count 42
total feature count 1918
"""
rows = [
("md5", doc["meta"]["sample"]["md5"]),
("sha1", doc["meta"]["sample"]["sha1"]),
("sha256", doc["meta"]["sample"]["sha256"]),
("path", doc["meta"]["sample"]["path"]),
("timestamp", doc["meta"]["timestamp"]),
("capa version", doc["meta"]["version"]),
("format", doc["meta"]["analysis"]["format"]),
("extractor", doc["meta"]["analysis"]["extractor"]),
("base address", hex(doc["meta"]["analysis"]["base_address"])),
("function count", len(doc["meta"]["analysis"]["feature_counts"]["functions"])),
(
"total feature count",
doc["meta"]["analysis"]["feature_counts"]["file"]
+ sum(doc["meta"]["analysis"]["feature_counts"]["functions"].values()),
),
]
ostream.writeln(tabulate.tabulate(rows, tablefmt="plain"))
ostream.write("\n")
def render_rules(ostream, doc):
"""
like:
receive data (2 matches)
namespace communication
description all known techniques for receiving data from a potential C2 server
scope function
matches 0x10003A13
0x10003797
"""
for rule in rutils.capability_rules(doc):
count = len(rule["matches"])
if count == 1:
@@ -62,4 +93,14 @@ def render_verbose(doc):
ostream.writeln(tabulate.tabulate(rows, tablefmt="plain"))
ostream.write("\n")
def render_verbose(doc):
ostream = rutils.StringIO()
render_meta(ostream, doc)
ostream.write("\n")
render_rules(ostream, doc)
ostream.write("\n")
return ostream.getvalue()

View File

@@ -1,7 +1,10 @@
import collections
import tabulate
import capa.rules
import capa.render.utils as rutils
import capa.render.verbose
def render_locations(ostream, match):
@@ -138,22 +141,23 @@ def render_match(ostream, match, indent=0, mode=MODE_SUCCESS):
render_match(ostream, child, indent=indent + 1, mode=child_mode)
def render_vverbose(doc):
ostream = rutils.StringIO()
rows = []
rows.append(("md5", doc["meta"]["sample"]["md5"]))
rows.append(("sha1", doc["meta"]["sample"]["sha1"]))
rows.append(("sha256", doc["meta"]["sample"]["sha256"]))
rows.append(("path", doc["meta"]["sample"]["path"]))
rows.append(("timestamp", doc["meta"]["timestamp"]))
rows.append(("capa version", doc["meta"]["version"]))
rows.append(("format", doc["meta"]["analysis"]["format"]))
rows.append(("extractor", doc["meta"]["analysis"]["extractor"]))
rows.append(("base address", hex(doc["meta"]["analysis"]["base_address"])))
ostream.writeln(tabulate.tabulate(rows, tablefmt="plain"))
ostream.write("\n")
def render_rules(ostream, doc):
"""
like:
## rules
check for OutputDebugString error
namespace anti-analysis/anti-debugging/debugger-detection
author michael.hunhoff@fireeye.com
scope function
mbc Anti-Behavioral Analysis::Detect Debugger::OutputDebugString
examples Practical Malware Analysis Lab 16-02.exe_:0x401020
function @ 0x10004706
and:
api: kernel32.SetLastError @ 0x100047C2
api: kernel32.GetLastError @ 0x10004A87
api: kernel32.OutputDebugString @ 0x10004767, 0x10004787, 0x10004816, 0x10004895
"""
for rule in rutils.capability_rules(doc):
count = len(rule["matches"])
if count == 1:
@@ -192,7 +196,16 @@ def render_vverbose(doc):
ostream.write(" @ ")
ostream.writeln(rutils.hex(location))
render_match(ostream, match, indent=1)
ostream.write("\n")
def render_vverbose(doc):
ostream = rutils.StringIO()
capa.render.verbose.render_meta(ostream, doc)
ostream.write("\n")
render_rules(ostream, doc)
ostream.write("\n")
return ostream.getvalue()

View File

@@ -180,7 +180,7 @@ class DoesntMatchExample(Lint):
try:
extractor = capa.main.get_extractor(path, "auto")
capabilities = capa.main.find_capabilities(ctx["rules"], extractor, disable_progress=True)
capabilities, meta = capa.main.find_capabilities(ctx["rules"], extractor, disable_progress=True)
except Exception as e:
logger.error("failed to extract capabilities: %s %s %s", rule.name, path, e)
return True

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python2
"""
show-capabilities-by-function
Invoke capa to extract the capabilities of the given sample
and emit the results grouped by function.
This is useful to identify "complex functions" - that is,
functions that implement a lot of different types of logic.
Example::
$ python scripts/show-capabilities-by-function.py /tmp/suspicious.dll_
function at 0x1000321A with 33 features:
- get hostname
- initialize Winsock library
function at 0x10003286 with 63 features:
- create thread
- terminate thread
function at 0x10003415 with 116 features:
- write file
- send data
- link function at runtime
- create HTTP request
- get common file path
- send HTTP request
- connect to HTTP server
function at 0x10003797 with 81 features:
- get socket status
- send data
- receive data
- create TCP socket
- send data on socket
- receive data on socket
- act as TCP client
- resolve DNS
- create UDP socket
- initialize Winsock library
- set socket configuration
- connect TCP socket
...
"""
import os
import sys
import logging
import collections
import argparse
import colorama
import capa.main
import capa.rules
import capa.engine
import capa.render
import capa.features
import capa.render.utils as rutils
import capa.features.freeze
import capa.features.extractors.viv
logger = logging.getLogger("capa.show-capabilities-by-function")
def render_matches_by_function(doc):
"""
like:
function at 0x1000321a with 33 features:
- get hostname
- initialize Winsock library
function at 0x10003286 with 63 features:
- create thread
- terminate thread
function at 0x10003415 with 116 features:
- write file
- send data
- link function at runtime
- create HTTP request
- get common file path
- send HTTP request
- connect to HTTP server
"""
ostream = rutils.StringIO()
matches_by_function = collections.defaultdict(set)
for rule in rutils.capability_rules(doc):
for va in rule["matches"].keys():
matches_by_function[va].add(rule["meta"]["name"])
for va, feature_count in sorted(doc["meta"]["analysis"]["feature_counts"]["functions"].items()):
va = int(va)
if not matches_by_function.get(va, {}):
continue
ostream.writeln("function at 0x%X with %d features: " % (va, feature_count))
for rule_name in matches_by_function[va]:
ostream.writeln(" - " + rule_name)
ostream.write("\n")
return ostream.getvalue()
def main(argv=None):
if argv is None:
argv = sys.argv[1:]
formats = [
("auto", "(default) detect file type automatically"),
("pe", "Windows PE file"),
("sc32", "32-bit shellcode"),
("sc64", "64-bit shellcode"),
("freeze", "features previously frozen by capa"),
]
format_help = ", ".join(["%s: %s" % (f[0], f[1]) for f in formats])
parser = argparse.ArgumentParser(description="detect capabilities in programs.")
parser.add_argument("sample", type=str, help="Path to sample to analyze")
parser.add_argument(
"-r",
"--rules",
type=str,
default="(embedded rules)",
help="Path to rule file or directory, use embedded rules by default",
)
parser.add_argument("-t", "--tag", type=str, help="Filter on rule meta field values")
parser.add_argument("-d", "--debug", action="store_true", help="Enable debugging output on STDERR")
parser.add_argument("-q", "--quiet", action="store_true", help="Disable all output but errors")
parser.add_argument(
"-f",
"--format",
choices=[f[0] for f in formats],
default="auto",
help="Select sample format, %s" % format_help,
)
args = parser.parse_args(args=argv)
if args.quiet:
logging.basicConfig(level=logging.ERROR)
logging.getLogger().setLevel(logging.ERROR)
elif args.debug:
logging.basicConfig(level=logging.DEBUG)
logging.getLogger().setLevel(logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
logging.getLogger().setLevel(logging.INFO)
# disable vivisect-related logging, it's verbose and not relevant for capa users
capa.main.set_vivisect_log_level(logging.CRITICAL)
# py2 doesn't know about cp65001, which is a variant of utf-8 on windows
# tqdm bails when trying to render the progress bar in this setup.
# because cp65001 is utf-8, we just map that codepage to the utf-8 codec.
# see #380 and: https://stackoverflow.com/a/3259271/87207
import codecs
codecs.register(lambda name: codecs.lookup("utf-8") if name == "cp65001" else None)
if args.rules == "(embedded rules)":
logger.info("-" * 80)
logger.info(" Using default embedded rules.")
logger.info(" To provide your own rules, use the form `capa.exe ./path/to/rules/ /path/to/mal.exe`.")
logger.info(" You can see the current default rule set here:")
logger.info(" https://github.com/fireeye/capa-rules")
logger.info("-" * 80)
logger.debug("detected running from source")
args.rules = os.path.join(os.path.dirname(__file__), "..", "rules")
logger.debug("default rule path (source method): %s", args.rules)
else:
logger.info("using rules path: %s", args.rules)
try:
rules = capa.main.get_rules(args.rules)
rules = capa.rules.RuleSet(rules)
logger.info("successfully loaded %s rules", len(rules))
if args.tag:
rules = rules.filter_rules_by_meta(args.tag)
logger.info("selected %s rules", len(rules))
except (IOError, capa.rules.InvalidRule, capa.rules.InvalidRuleSet) as e:
logger.error("%s", str(e))
return -1
with open(args.sample, "rb") as f:
taste = f.read(8)
if (args.format == "freeze") or (args.format == "auto" and capa.features.freeze.is_freeze(taste)):
format = "freeze"
with open(args.sample, "rb") as f:
extractor = capa.features.freeze.load(f.read())
else:
format = args.format
try:
extractor = capa.main.get_extractor(args.sample, args.format)
except capa.main.UnsupportedFormatError:
logger.error("-" * 80)
logger.error(" Input file does not appear to be a PE file.")
logger.error(" ")
logger.error(
" capa currently only supports analyzing PE files (or shellcode, when using --format sc32|sc64)."
)
logger.error(
" If you don't know the input file type, you can try using the `file` utility to guess it."
)
logger.error("-" * 80)
return -1
except capa.main.UnsupportedRuntimeError:
logger.error("-" * 80)
logger.error(" Unsupported runtime or Python interpreter.")
logger.error(" ")
logger.error(" capa supports running under Python 2.7 using Vivisect for binary analysis.")
logger.error(" It can also run within IDA Pro, using either Python 2.7 or 3.5+.")
logger.error(" ")
logger.error(
" If you're seeing this message on the command line, please ensure you're running Python 2.7."
)
logger.error("-" * 80)
return -1
meta = capa.main.collect_metadata(argv, args.sample, format, extractor)
capabilities, counts = capa.main.find_capabilities(rules, extractor)
meta["analysis"].update(counts)
if capa.main.has_file_limitation(rules, capabilities):
# bail if capa encountered file limitation e.g. a packed binary
# do show the output in verbose mode, though.
if not (args.verbose or args.vverbose or args.json):
return -1
# colorama will detect:
# - when on Windows console, and fixup coloring, and
# - when not an interactive session, and disable coloring
# renderers should use coloring and assume it will be stripped out if necessary.
colorama.init()
doc = capa.render.convert_capabilities_to_result_document(meta, rules, capabilities)
print(render_matches_by_function(doc))
colorama.deinit()
logger.info("done.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,6 +1,60 @@
#!/usr/bin/env python2
"""
show the features extracted by capa.
show-features
Show the features that capa extracts from the given sample,
to assist with the development of rules.
If you have a function with a capability that you'd like to detect,
you can run this tool and grep for the function/basic block/instruction addresses
to see what capa picks up.
This way, you can verify that capa successfully notices the features you'd reference.
Example::
$ python scripts/show-features.py /tmp/suspicious.dll_
...
file: 0x10004e4d: export(__entry)
file: 0x10004706: export(Install)
file: 0x10004c2b: export(uninstallA)
file: 0x10005034: import(kernel32.GetStartupInfoA)
file: 0x10005034: import(GetStartupInfoA)
file: 0x10005048: import(kernel32.SetLastError)
file: 0x00004e10: string(Y29ubmVjdA==)
file: 0x00004e28: string(practicalmalwareanalysis.com)
file: 0x00004e68: string(serve.html)
file: 0x00004eb8: string(dW5zdXBwb3J0)
file: 0x00004ec8: string(c2xlZXA=)
func: 0x100012c2: characteristic(calls to)
func: 0x10001000: characteristic(loop)
bb : 0x10001000: basic block
insn: 0x10001000: mnemonic(push)
insn: 0x10001001: mnemonic(push)
insn: 0x10001002: mnemonic(push)
insn: 0x10001003: mnemonic(push)
insn: 0x10001004: mnemonic(push)
insn: 0x10001005: mnemonic(push)
insn: 0x10001006: mnemonic(xor)
insn: 0x10001008: number(0x1)
insn: 0x10001008: mnemonic(mov)
bb : 0x1000100a: basic block
bb : 0x1000100a: characteristic(tight loop)
insn: 0x1000100a: mnemonic(movzx)
insn: 0x1000100d: mnemonic(mov)
insn: 0x1000100f: offset(0x1000A7C8)
insn: 0x1000100f: mnemonic(mov)
insn: 0x10001015: offset(0x100075C8)
insn: 0x10001015: mnemonic(mov)
insn: 0x1000101b: mnemonic(mov)
insn: 0x1000101d: number(0x80)
insn: 0x1000101d: mnemonic(and)
insn: 0x10001020: mnemonic(neg)
insn: 0x10001022: mnemonic(sbb)
insn: 0x10001024: number(0x1B)
insn: 0x10001024: mnemonic(and)
insn: 0x10001027: number(0x1)
insn: 0x10001027: mnemonic(shl)
...
"""
import sys
import logging

View File

@@ -93,7 +93,8 @@ def get_capabilities(path, rules):
logger.debug("matching rules in %s", path)
with open(path, "rb") as f:
extractor = capa.features.freeze.load(f.read())
return capa.main.find_capabilities(rules, extractor, disable_progress=True)
capabilities, meta = capa.main.find_capabilities(rules, extractor, disable_progress=True)
return capabilities
def get_function_hits(capabilities, rule_name):

View File

@@ -41,13 +41,15 @@ setuptools.setup(
include_package_data=True,
install_requires=requirements,
extras_require={
"dev": ["pytest",
"pytest-sugar",
"pytest-instafail",
"pytest-cov",
"pycodestyle",
"black ; python_version>'3.0'",
"isort"]
"dev": [
"pytest",
"pytest-sugar",
"pytest-instafail",
"pytest-cov",
"pycodestyle",
"black ; python_version>'3.0'",
"isort",
]
},
zip_safe=False,
keywords="capa",

View File

@@ -59,7 +59,7 @@ def test_null_feature_extractor():
),
]
)
capabilities = capa.main.find_capabilities(rules, EXTRACTOR)
capabilities, meta = capa.main.find_capabilities(rules, EXTRACTOR)
assert "xor loop" in capabilities

View File

@@ -147,7 +147,7 @@ def test_match_across_scopes_file_function(sample_9324d1a8ae37a36ae560c37448c970
extractor = capa.features.extractors.viv.VivisectFeatureExtractor(
sample_9324d1a8ae37a36ae560c37448c9705a.vw, sample_9324d1a8ae37a36ae560c37448c9705a.path,
)
capabilities = capa.main.find_capabilities(rules, extractor)
capabilities, meta = capa.main.find_capabilities(rules, extractor)
assert "install service" in capabilities
assert ".text section" in capabilities
assert ".text section and install service" in capabilities
@@ -212,7 +212,7 @@ def test_match_across_scopes(sample_9324d1a8ae37a36ae560c37448c9705a):
extractor = capa.features.extractors.viv.VivisectFeatureExtractor(
sample_9324d1a8ae37a36ae560c37448c9705a.vw, sample_9324d1a8ae37a36ae560c37448c9705a.path
)
capabilities = capa.main.find_capabilities(rules, extractor)
capabilities, meta = capa.main.find_capabilities(rules, extractor)
assert "tight loop" in capabilities
assert "kill thread loop" in capabilities
assert "kill thread program" in capabilities
@@ -241,7 +241,7 @@ def test_subscope_bb_rules(sample_9324d1a8ae37a36ae560c37448c9705a):
extractor = capa.features.extractors.viv.VivisectFeatureExtractor(
sample_9324d1a8ae37a36ae560c37448c9705a.vw, sample_9324d1a8ae37a36ae560c37448c9705a.path,
)
capabilities = capa.main.find_capabilities(rules, extractor)
capabilities, meta = capa.main.find_capabilities(rules, extractor)
assert "test rule" in capabilities
@@ -267,7 +267,7 @@ def test_byte_matching(sample_9324d1a8ae37a36ae560c37448c9705a):
extractor = capa.features.extractors.viv.VivisectFeatureExtractor(
sample_9324d1a8ae37a36ae560c37448c9705a.vw, sample_9324d1a8ae37a36ae560c37448c9705a.path,
)
capabilities = capa.main.find_capabilities(rules, extractor)
capabilities, meta = capa.main.find_capabilities(rules, extractor)
assert "byte match test" in capabilities
@@ -294,5 +294,5 @@ def test_count_bb(sample_9324d1a8ae37a36ae560c37448c9705a):
extractor = capa.features.extractors.viv.VivisectFeatureExtractor(
sample_9324d1a8ae37a36ae560c37448c9705a.vw, sample_9324d1a8ae37a36ae560c37448c9705a.path,
)
capabilities = capa.main.find_capabilities(rules, extractor)
capabilities, meta = capa.main.find_capabilities(rules, extractor)
assert "count bb" in capabilities