Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add flavored scopes #1580

Merged
merged 40 commits into from
Jul 12, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
cfad228
scope flavors: add a Flavor class
yelhamer Jun 30, 2023
c4bb4d9
update changelog
yelhamer Jun 30, 2023
e726c78
ensure_feature_valid_for_scope(): add support for flavored scopes
yelhamer Jun 30, 2023
6f05665
tests: add unit tests for flavored scopes
yelhamer Jun 30, 2023
ae5f2ec
fix mypy issues
yelhamer Jul 1, 2023
d2ff0af
Revert "tests: add unit tests for flavored scopes"
yelhamer Jul 1, 2023
8a93a06
fix mypy issues
yelhamer Jul 1, 2023
21cecb2
tests: add unit tests for flavored scopes
yelhamer Jul 1, 2023
f1d7ac3
Update test_rules.py
yelhamer Jul 3, 2023
1b59efc
Apply suggestions from code review: rename Flavor to Scopes
yelhamer Jul 3, 2023
c042a28
rename Flavor to Scopes
yelhamer Jul 3, 2023
8ba86e9
add update Scopes class and switch scope to scopes
yelhamer Jul 5, 2023
9ffe85f
build_statements: add support for scope flavors
yelhamer Jul 5, 2023
19e40a3
address review comments
yelhamer Jul 5, 2023
9300e68
fix mypy issues in test_rules.py
yelhamer Jul 5, 2023
4649c9a
rename rule.scope to rule.scope in ida plugin
yelhamer Jul 5, 2023
47aebcb
fix show-capabilities-by-function
yelhamer Jul 5, 2023
32f936c
address review comments
yelhamer Jul 6, 2023
c916e3b
update the linter
yelhamer Jul 6, 2023
0c56291
update linter
yelhamer Jul 6, 2023
a8f722c
xfail tests that require the old ruleset
yelhamer Jul 6, 2023
9dd65bf
extract_subscope_rules(): use DEV_SCOPE
yelhamer Jul 7, 2023
fa7a7c2
replace usage of __dict__ with dataclasses.asdict()
yelhamer Jul 7, 2023
5e295f5
DEV_SCOPE: add todo comment
yelhamer Jul 7, 2023
03b0493
Scopes class: remove __eq__ operator overriding and override __in__ i…
yelhamer Jul 7, 2023
605fbaf
add import asdict from dataclasses
yelhamer Jul 7, 2023
a2d6bd6
Merge branch 'dynamic-feature-extraction' into analysis-flavor
williballenthin Jul 10, 2023
917dd8b
Update scripts/lint.py
yelhamer Jul 10, 2023
ec59886
Update capa/rules/__init__.py
yelhamer Jul 10, 2023
f86ecfe
Merge remote-tracking branch 'parentrepo/dynamic-feature-extraction' …
yelhamer Jul 11, 2023
6feb9f5
fix ruff linting issues
yelhamer Jul 11, 2023
0db7141
remove redundant import
yelhamer Jul 11, 2023
7e18eed
update ruff.toml
yelhamer Jul 11, 2023
0e312d6
replace unused variable 'r' with '_'
yelhamer Jul 11, 2023
12c9154
fix flake8 linting issues
yelhamer Jul 11, 2023
4ee38cb
fix linting issues
yelhamer Jul 11, 2023
34d3d6c
Merge remote-tracking branch 'origin/analysis-flavor' into yelhamer-a…
yelhamer Jul 12, 2023
1703039
ida/plugin/form.py: replace usage of '==' with usage of 'in' operator
yelhamer Jul 12, 2023
53d897d
ida/plugin/form.py: replace list comprehension in any() with a generator
yelhamer Jul 12, 2023
9c87845
fix typo: replace 'rules' with 'rule'
yelhamer Jul 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Add a CAPE file format and CAPE-based dynamic feature extraction to scripts/show-features.py #1566 @yelhamer
- Add a new process scope for the dynamic analysis flavor #1517 @yelhamer
- Add a new thread scope for the dynamic analysis flavor #1517 @yelhamer
- Add support for flavor-based rule scopes @yelhamer

### Breaking Changes
- Update Metadata type in capa main [#1411](https://github.com/mandiant/capa/issues/1411) [@Aayush-Goel-04](https://github.com/aayush-goel-04) @manasghandat
Expand Down
98 changes: 83 additions & 15 deletions capa/rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,42 @@ class Scope(str, Enum):
GLOBAL_SCOPE = "global"


# these literals are used to check if the flavor
# of a rule is correct.
STATIC_SCOPES = (
FILE_SCOPE,
GLOBAL_SCOPE,
FUNCTION_SCOPE,
BASIC_BLOCK_SCOPE,
INSTRUCTION_SCOPE,
)
DYNAMIC_SCOPES = (
FILE_SCOPE,
GLOBAL_SCOPE,
PROCESS_SCOPE,
THREAD_SCOPE,
)


class Flavor:
def __init__(self, static: str, dynamic: str, definition=""):
self.static = static if static in STATIC_SCOPES else ""
self.dynamic = dynamic if dynamic in DYNAMIC_SCOPES else ""
yelhamer marked this conversation as resolved.
Show resolved Hide resolved
self.definition = definition

if static != self.static:
raise InvalidRule(f"'{static}' is not a valid static scope")
if dynamic != self.dynamic:
raise InvalidRule(f"'{dynamic}' is not a valid dynamic scope")
if (not self.static) and (not self.dynamic):
raise InvalidRule("rule must have at least one scope specified")

def __eq__(self, scope) -> bool:
# Flavors aren't supposed to be compared directly.
assert isinstance(scope, Scope) or isinstance(scope, str)
return (scope == self.static) or (scope == self.dynamic)


SUPPORTED_FEATURES: Dict[str, Set] = {
GLOBAL_SCOPE: {
# these will be added to other scopes, see below.
Expand Down Expand Up @@ -215,20 +251,31 @@ def __repr__(self):
return str(self)


def ensure_feature_valid_for_scope(scope: str, feature: Union[Feature, Statement]):
def ensure_feature_valid_for_scope(scope: Union[str, Flavor], feature: Union[Feature, Statement]):
yelhamer marked this conversation as resolved.
Show resolved Hide resolved
# if the given feature is a characteristic,
# check that is a valid characteristic for the given scope.
supported_features = set()
if isinstance(scope, Flavor):
if scope.static:
supported_features.update(SUPPORTED_FEATURES[scope.static])
if scope.dynamic:
supported_features.update(SUPPORTED_FEATURES[scope.dynamic])
elif isinstance(scope, str):
supported_features.update(SUPPORTED_FEATURES[scope])
else:
raise InvalidRule(f"{scope} is not a valid scope")

if (
isinstance(feature, capa.features.common.Characteristic)
and isinstance(feature.value, str)
and capa.features.common.Characteristic(feature.value) not in SUPPORTED_FEATURES[scope]
and capa.features.common.Characteristic(feature.value) not in supported_features
):
raise InvalidRule(f"feature {feature} not supported for scope {scope}")

if not isinstance(feature, capa.features.common.Characteristic):
# features of this scope that are not Characteristics will be Type instances.
# check that the given feature is one of these types.
types_for_scope = filter(lambda t: isinstance(t, type), SUPPORTED_FEATURES[scope])
types_for_scope = filter(lambda t: isinstance(t, type), supported_features)
if not isinstance(feature, tuple(types_for_scope)): # type: ignore
raise InvalidRule(f"feature {feature} not supported for scope {scope}")

Expand Down Expand Up @@ -438,7 +485,7 @@ def pop_statement_description_entry(d):
return description["description"]


def build_statements(d, scope: str):
def build_statements(d, scope: Union[str, Flavor]):
yelhamer marked this conversation as resolved.
Show resolved Hide resolved
if len(d.keys()) > 2:
raise InvalidRule("too many statements")

Expand Down Expand Up @@ -647,8 +694,29 @@ def second(s: List[Any]) -> Any:
return s[1]


def parse_flavor(scope: Union[str, Dict[str, str]]) -> Flavor:
if isinstance(scope, str):
if scope in STATIC_SCOPES:
return Flavor(scope, "", definition=scope)
elif scope in DYNAMIC_SCOPES:
return Flavor("", scope, definition=scope)
else:
raise InvalidRule(f"{scope} is not a valid scope")
yelhamer marked this conversation as resolved.
Show resolved Hide resolved
elif isinstance(scope, dict):
if "static" not in scope:
scope.update({"static": ""})
if "dynamic" not in scope:
scope.update({"dynamic": ""})
if len(scope) != 2:
raise InvalidRule("scope flavors can be either static or dynamic")
else:
return Flavor(scope["static"], scope["dynamic"], definition=scope)
else:
raise InvalidRule(f"scope field is neither a scope's name or a flavor list")


class Rule:
def __init__(self, name: str, scope: str, statement: Statement, meta, definition=""):
def __init__(self, name: str, scope: Union[Flavor, str], statement: Statement, meta, definition=""):
super().__init__()
self.name = name
self.scope = scope
Expand Down Expand Up @@ -788,7 +856,10 @@ def from_dict(cls, d: Dict[str, Any], definition: str) -> "Rule":
name = meta["name"]
# if scope is not specified, default to function scope.
# this is probably the mode that rule authors will start with.
# each rule has two scopes, a static-flavor scope, and a
# dynamic-flavor one. which one is used depends on the analysis type.
scope = meta.get("scope", FUNCTION_SCOPE)
yelhamer marked this conversation as resolved.
Show resolved Hide resolved
scope = parse_flavor(scope)
yelhamer marked this conversation as resolved.
Show resolved Hide resolved
statements = d["rule"]["features"]

# the rule must start with a single logic node.
Expand All @@ -799,9 +870,6 @@ def from_dict(cls, d: Dict[str, Any], definition: str) -> "Rule":
if isinstance(statements[0], ceng.Subscope):
raise InvalidRule("top level statement may not be a subscope")

if scope not in SUPPORTED_FEATURES.keys():
raise InvalidRule("{:s} is not a supported scope".format(scope))

meta = d["rule"]["meta"]
if not isinstance(meta.get("att&ck", []), list):
raise InvalidRule("ATT&CK mapping must be a list")
Expand Down Expand Up @@ -910,7 +978,7 @@ def to_yaml(self) -> str:

# the name and scope of the rule instance overrides anything in meta.
meta["name"] = self.name
meta["scope"] = self.scope
meta["scope"] = self.scope.definition if isinstance(self.scope, Flavor) else self.scope
yelhamer marked this conversation as resolved.
Show resolved Hide resolved

def move_to_end(m, k):
# ruamel.yaml uses an ordereddict-like structure to track maps (CommentedMap).
Expand Down Expand Up @@ -1399,22 +1467,22 @@ def match(self, scope: Scope, features: FeatureSet, addr: Address) -> Tuple[Feat
except that it may be more performant.
"""
easy_rules_by_feature = {}
if scope is Scope.FILE:
if scope == Scope.FILE:
easy_rules_by_feature = self._easy_file_rules_by_feature
hard_rule_names = self._hard_file_rules
elif scope is Scope.PROCESS:
elif scope == Scope.PROCESS:
easy_rules_by_feature = self._easy_process_rules_by_feature
hard_rule_names = self._hard_process_rules
elif scope is Scope.THREAD:
elif scope == Scope.THREAD:
easy_rules_by_feature = self._easy_thread_rules_by_feature
hard_rule_names = self._hard_thread_rules
elif scope is Scope.FUNCTION:
elif scope == Scope.FUNCTION:
easy_rules_by_feature = self._easy_function_rules_by_feature
hard_rule_names = self._hard_function_rules
elif scope is Scope.BASIC_BLOCK:
elif scope == Scope.BASIC_BLOCK:
easy_rules_by_feature = self._easy_basic_block_rules_by_feature
hard_rule_names = self._hard_basic_block_rules
elif scope is Scope.INSTRUCTION:
elif scope == Scope.INSTRUCTION:
easy_rules_by_feature = self._easy_instruction_rules_by_feature
hard_rule_names = self._hard_instruction_rules
else:
Expand Down
98 changes: 91 additions & 7 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,25 +376,49 @@ def test_subscope_rules():
"""
)
),
capa.rules.Rule.from_yaml(
textwrap.dedent(
"""
rule:
meta:
name: test subscopes for scope flavors
scope:
static: function
dynamic: process
features:
- and:
- string: /etc/shadow
- or:
- api: open
- instruction:
- mnemonic: syscall
- number: 2 = open syscall number
"""
)
),
]
)
# the file rule scope will have two rules:
# - `test function subscope` and `test process subscope`
assert len(rules.file_rules) == 2

# the function rule scope have one rule:
# - the rule on which `test function subscope` depends
assert len(rules.function_rules) == 1
# the function rule scope have two rule:
# - the rule on which `test function subscope` depends, and
# the `test subscopes for scope flavors` rule
assert len(rules.function_rules) == 2

# the process rule scope has one rule:
# - the rule on which `test process subscope` and depends
# as well as `test thread scope`
assert len(rules.process_rules) == 2
# the process rule scope has three rules:
# - the rule on which `test process subscope` depends,
# `test thread scope` , and `test subscopes for scope flavors`
assert len(rules.process_rules) == 3

# the thread rule scope has one rule:
# - the rule on which `test thread subscope` depends
assert len(rules.thread_rules) == 1

# the rule on which `test subscopes for scope flavors` depends
assert len(rules.instruction_rules) == 1


def test_duplicate_rules():
with pytest.raises(capa.rules.InvalidRule):
Expand Down Expand Up @@ -499,6 +523,66 @@ def test_invalid_rules():
"""
)
)
with pytest.raises(capa.rules.InvalidRule):
yelhamer marked this conversation as resolved.
Show resolved Hide resolved
r = capa.rules.Rule.from_yaml(
textwrap.dedent(
"""
rule:
meta:
name: test rule
scope:
static: basic block
behavior: process
features:
- number: 1
"""
)
)
with pytest.raises(capa.rules.InvalidRule):
r = capa.rules.Rule.from_yaml(
textwrap.dedent(
"""
rule:
meta:
name: test rule
scope:
legacy: basic block
dynamic: process
features:
- number: 1
"""
)
)
with pytest.raises(capa.rules.InvalidRule):
r = capa.rules.Rule.from_yaml(
textwrap.dedent(
"""
rule:
meta:
name: test rule
scope:
static: process
dynamic: process
features:
- number: 1
"""
)
)
with pytest.raises(capa.rules.InvalidRule):
r = capa.rules.Rule.from_yaml(
textwrap.dedent(
"""
rule:
meta:
name: test rule
scope:
static: basic block
dynamic: function
features:
- number: 1
"""
)
)


def test_number_symbol():
Expand Down