Files
capa/capa/ida/plugin/view.py
Ana Maria Martinez Gomez 3cd97ae9f2 [copyright + license] Fix headers
Replace the header from source code files using the following script:
```Python
for dir_path, dir_names, file_names in os.walk("capa"):
    for file_name in file_names:
        # header are only in `.py` and `.toml` files
        if file_name[-3:] not in (".py", "oml"):
            continue
        file_path = f"{dir_path}/{file_name}"
        f = open(file_path, "rb+")
        content = f.read()
        m = re.search(OLD_HEADER, content)
        if not m:
            continue
        print(f"{file_path}: {m.group('year')}")
        content = content.replace(m.group(0), NEW_HEADER % m.group("year"))
        f.seek(0)
        f.write(content)
```

Some files had the copyright headers inside a `"""` comment and needed
manual changes before applying the script. `hook-vivisect.py` and
`pyinstaller.spec` didn't include the license in the header and also
needed manual changes.

The old header had the confusing sentence `All rights reserved`, which
does not make sense for an open source license. Replace the header by
the default Google header that corrects this issue and keep capa
consistent with other Google projects.

Adapt the linter to work with the new header.

Replace also the copyright text in the `web/public/index.html` file for
consistency.
2025-01-15 08:52:42 -07:00

1366 lines
48 KiB
Python

# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License 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 re
from typing import Optional
from collections import Counter
import idc
import idaapi
from PyQt5 import QtGui, QtCore, QtWidgets
import capa.rules
import capa.engine
import capa.ida.helpers
import capa.features.common
import capa.features.basicblock
from capa.ida.plugin.item import CapaExplorerFunctionItem
from capa.features.address import AbsoluteVirtualAddress, _NoAddress
from capa.ida.plugin.model import CapaExplorerDataModel
MAX_SECTION_SIZE = 750
# default colors used in views
COLOR_GREEN_RGB = (79, 121, 66)
COLOR_BLUE_RGB = (37, 147, 215)
def calc_indent_from_line(line, prev_level=0):
""" """
if not len(line.strip()):
# blank line, which may occur for comments so we simply use the last level
return prev_level
stripped = line.lstrip()
if stripped.startswith("description"):
# need to adjust two spaces when encountering string description
line = line[2:]
# calc line level based on preceding whitespace
indent = len(line) - len(stripped)
# round up to nearest even number; helps keep parsing more sane
return indent + (indent % 2)
def parse_yaml_line(feature):
""" """
description = ""
comment = ""
if feature.startswith("- count"):
# count is weird, we need to handle special
# first, we need to grab the comment, if exists
# next, we need to check for an embedded description
feature, _, comment = feature.partition("#")
m = re.search(r"- count\(([a-zA-Z]+)\((.+)\s+=\s+(.+)\)\):\s*(.+)", feature)
if m:
# reconstruct count without description
feature, value, description, count = m.groups()
feature = f"- count({feature}({value})): {count}"
elif not feature.startswith("#"):
feature, _, comment = feature.partition("#")
feature, _, description = feature.partition("=")
return (o.strip() for o in (feature, description, comment))
def parse_node_for_feature(feature, description, comment, depth):
""" """
depth = (depth * 2) + 4
display = ""
if feature.startswith("#"):
display += f"{' '*depth}{feature}\n"
elif description:
if feature.startswith(("- and", "- or", "- optional", "- basic block", "- not", "- instruction:")):
display += f"{' '*depth}{feature}\n"
if comment:
display += f" # {comment}"
display += f"\n{' '*(depth+2)}- description: {description}\n"
elif feature.startswith("- string"):
display += f"{' '*depth}{feature}\n"
if comment:
display += f" # {comment}"
display += f"\n{' '*(depth+2)}description: {description}\n"
elif feature.startswith("- count"):
# count is weird, we need to format description based on feature type, so we parse with regex
# assume format - count(<feature_name>(<feature_value>)): <count>
m = re.search(r"- count\(([a-zA-Z]+)\((.+)\)\): (.+)", feature)
if m:
name, value, count = m.groups()
if name in ("string",):
display += f"{' '*depth}{feature}"
if comment:
display += f" # {comment}"
display += f"\n{' '*(depth+2)}description: {description}\n"
else:
display += f"{' '*depth}- count({name}({value} = {description})): {count}"
if comment:
display += f" # {comment}\n"
else:
display += f"{' '*depth}{feature} = {description}"
if comment:
display += f" # {comment}\n"
else:
display += f"{' '*depth}{feature}"
if comment:
display += f" # {comment}\n"
return display if display.endswith("\n") else display + "\n"
def iterate_tree(o):
""" """
itr = QtWidgets.QTreeWidgetItemIterator(o)
while itr.value():
yield itr.value()
itr += 1
def expand_tree(root):
""" """
for node in iterate_tree(root):
if node.childCount() and not node.isExpanded():
node.setExpanded(True)
def calc_item_depth(o):
""" """
depth = 0
while True:
if not o.parent():
break
depth += 1
o = o.parent()
return depth
def build_action(o, display, data, slot):
""" """
action = QtWidgets.QAction(display, o)
action.setData(data)
action.triggered.connect(lambda checked: slot(action))
return action
def build_context_menu(o, actions):
""" """
menu = QtWidgets.QMenu()
for action in actions:
if isinstance(action, QtWidgets.QMenu):
menu.addMenu(action)
else:
menu.addAction(build_action(o, *action))
return menu
def resize_columns_to_content(header):
""" """
header.resizeSections(QtWidgets.QHeaderView.ResizeToContents)
if header.sectionSize(0) > MAX_SECTION_SIZE:
header.resizeSection(0, MAX_SECTION_SIZE)
class CapaExplorerRulegenPreview(QtWidgets.QTextEdit):
INDENT = " " * 2
def __init__(self, parent=None):
""" """
super().__init__(parent)
self.setFont(QtGui.QFont("Courier", weight=QtGui.QFont.Bold))
self.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
self.setAcceptRichText(False)
def reset_view(self):
""" """
self.clear()
def load_preview_meta(self, ea, author, scope):
""" """
metadata_default = [
"# generated using capa explorer for IDA Pro",
"rule:",
" meta:",
" name: <insert_name>",
" namespace: <insert_namespace>",
" authors:",
f" - {author}",
" scopes:",
f" static: {scope}",
" dynamic: unsupported",
" references:",
" - <insert_references>",
" examples:",
(
f" - {capa.ida.helpers.get_file_md5().upper()}:{hex(ea)}"
if ea
else f" - {capa.ida.helpers.get_file_md5().upper()}"
),
" features:",
]
self.setText("\n".join(metadata_default))
def keyPressEvent(self, e):
"""intercept key press events"""
if e.key() in (QtCore.Qt.Key_Tab, QtCore.Qt.Key_Backtab):
# apparently it's not easy to implement tabs as spaces, or multi-line tab or SHIFT + Tab
# so we need to implement it ourselves so we can retain properly formatted capa rules
# when a user uses the Tab key
if self.textCursor().selection().isEmpty():
# single line, only worry about Tab
if e.key() == QtCore.Qt.Key_Tab:
self.insertPlainText(self.INDENT)
else:
# multi-line tab or SHIFT + Tab
cur = self.textCursor()
select_start_ppos = cur.selectionStart()
select_end_ppos = cur.selectionEnd()
scroll_ppos = self.verticalScrollBar().sliderPosition()
# determine lineno for first selected line, and column
cur.setPosition(select_start_ppos)
start_lineno = self.count_previous_lines_from_block(cur.block())
start_lineco = cur.columnNumber()
# determine lineno for last selected line
cur.setPosition(select_end_ppos)
end_lineno = self.count_previous_lines_from_block(cur.block())
# now we need to indent or dedent the selected lines. for now, we read the text, modify
# the lines between start_lineno and end_lineno accordingly, and then reset the view
# this might not be the best solution, but it avoids messing around with cursor positions
# to determine the beginning of lines
plain = self.toPlainText().splitlines()
if e.key() == QtCore.Qt.Key_Tab:
# user Tab, indent selected lines
lines_modified = end_lineno - start_lineno
first_modified = True
change = [self.INDENT + line for line in plain[start_lineno : end_lineno + 1]]
else:
# user SHIFT + Tab, dedent selected lines
lines_modified = 0
first_modified = False
change = []
for lineno, line in enumerate(plain[start_lineno : end_lineno + 1]):
if line.startswith(self.INDENT):
if lineno == 0:
# keep track if first line is modified, so we can properly display
# the text selection later
first_modified = True
lines_modified += 1
line = line[len(self.INDENT) :]
change.append(line)
# apply modifications, and reset view
plain[start_lineno : end_lineno + 1] = change
self.setPlainText("\n".join(plain) + "\n")
# now we need to properly adjust the selection positions, so users don't have to
# re-select when indenting or dedenting the same lines repeatedly
if e.key() == QtCore.Qt.Key_Tab:
# user Tab, increase increment selection positions
select_start_ppos += len(self.INDENT)
select_end_ppos += (lines_modified * len(self.INDENT)) + len(self.INDENT)
elif lines_modified:
# user SHIFT + Tab, decrease selection positions
if start_lineco not in (0, 1) and first_modified:
# only decrease start position if not in first column
select_start_ppos -= len(self.INDENT)
select_end_ppos -= lines_modified * len(self.INDENT)
# apply updated selection and restore previous scroll position
self.set_selection(select_start_ppos, select_end_ppos, len(self.toPlainText()))
self.verticalScrollBar().setSliderPosition(scroll_ppos)
else:
super().keyPressEvent(e)
def count_previous_lines_from_block(self, block):
"""calculate number of lines preceding block"""
count = 0
while True:
block = block.previous()
if not block.isValid():
break
count += block.lineCount()
return count
def set_selection(self, start, end, max):
"""set text selection"""
cursor = self.textCursor()
cursor.setPosition(start)
cursor.setPosition(end if end < max else max, QtGui.QTextCursor.KeepAnchor)
self.setTextCursor(cursor)
class CapaExplorerRulegenEditor(QtWidgets.QTreeWidget):
updated = QtCore.pyqtSignal()
def __init__(self, preview, parent=None):
""" """
super().__init__(parent)
self.preview = preview
self.setHeaderLabels(["Feature", "Description", "Comment"])
self.header().setStretchLastSection(False)
self.setExpandsOnDoubleClick(False)
self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setStyleSheet("QTreeView::item {padding-right: 15 px;padding-bottom: 2 px;}")
# configure view columns to auto-resize
for idx in range(3):
self.header().setSectionResizeMode(idx, QtWidgets.QHeaderView.Interactive)
# enable drag and drop
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
# connect slots
self.itemChanged.connect(self.slot_item_changed)
self.customContextMenuRequested.connect(self.slot_custom_context_menu_requested)
self.itemDoubleClicked.connect(self.slot_item_double_clicked)
self.expanded.connect(self.slot_resize_columns_to_content)
self.collapsed.connect(self.slot_resize_columns_to_content)
self.reset_view()
self.is_editing = False
@staticmethod
def get_column_feature_index():
""" """
return 0
@staticmethod
def get_column_description_index():
""" """
return 1
@staticmethod
def get_column_comment_index():
""" """
return 2
@staticmethod
def get_node_type_expression():
""" """
return 0
@staticmethod
def get_node_type_feature():
""" """
return 1
@staticmethod
def get_node_type_comment():
""" """
return 2
def dragMoveEvent(self, e):
""" """
super().dragMoveEvent(e)
def dragEventEnter(self, e):
""" """
super().dragEventEnter(e)
def dropEvent(self, e):
""" """
if not self.indexAt(e.pos()).isValid():
return
super().dropEvent(e)
self.update_preview()
expand_tree(self.invisibleRootItem())
def reset_view(self):
""" """
self.clear()
def slot_resize_columns_to_content(self):
""" """
resize_columns_to_content(self.header())
def slot_item_changed(self, item, column):
""" """
if self.is_editing:
self.update_preview()
self.is_editing = False
def slot_remove_selected(self, action):
""" """
for o in self.selectedItems():
if o.parent() is None:
# special handling for top-level items
self.takeTopLevelItem(self.indexOfTopLevelItem(o))
continue
o.parent().removeChild(o)
def slot_nest_features(self, action):
""" """
# we don't want to add new features under the invisible root because capa rules should
# contain a single top-level node; this may not always be the case so we default to the last
# child node that was added to the invisible root
top_node = self.invisibleRootItem().child(self.invisibleRootItem().childCount() - 1)
# create a new parent under top-level node
new_parent = self.new_expression_node(top_node, (action.data()[0], ""))
if "basic block" in action.data()[0]:
# add default child expression when nesting under basic block
new_parent.setExpanded(True)
new_parent = self.new_expression_node(new_parent, ("- or:", ""))
elif "instruction" in action.data()[0]:
# add default child expression when nesting under instruction
new_parent.setExpanded(True)
new_parent = self.new_expression_node(new_parent, ("- or:", ""))
for o in self.get_features(selected=True):
# take child from its parent by index, add to new parent
new_parent.addChild(o.parent().takeChild(o.parent().indexOfChild(o)))
# ensure new parent expanded
new_parent.setExpanded(True)
def slot_edit_expression(self, action):
""" """
expression, o = action.data()
if "basic block" in expression and "basic block" not in o.text(
CapaExplorerRulegenEditor.get_column_feature_index()
):
# current expression is "basic block", and not changing to "basic block" expression
children = o.takeChildren()
new_parent = self.new_expression_node(o, ("- or:", ""))
for child in children:
new_parent.addChild(child)
new_parent.setExpanded(True)
elif "instruction" in expression and "instruction" not in o.text(
CapaExplorerRulegenEditor.get_column_feature_index()
):
# current expression is "instruction", and not changing to "instruction" expression
children = o.takeChildren()
new_parent = self.new_expression_node(o, ("- or:", ""))
for child in children:
new_parent.addChild(child)
new_parent.setExpanded(True)
o.setText(CapaExplorerRulegenEditor.get_column_feature_index(), expression)
def slot_clear_all(self, action):
""" """
self.reset_view()
def slot_custom_context_menu_requested(self, pos):
""" """
if not self.indexAt(pos).isValid():
# user selected invalid index
self.load_custom_context_menu_invalid_index(pos)
elif self.itemAt(pos).capa_type == CapaExplorerRulegenEditor.get_node_type_expression():
# user selected expression node
self.load_custom_context_menu_expression(pos)
else:
# user selected feature node
self.load_custom_context_menu_feature(pos)
self.update_preview()
def slot_item_double_clicked(self, o, column):
""" """
if column in (
CapaExplorerRulegenEditor.get_column_comment_index(),
CapaExplorerRulegenEditor.get_column_description_index(),
):
o.setFlags(o.flags() | QtCore.Qt.ItemIsEditable)
self.editItem(o, column)
o.setFlags(o.flags() & ~QtCore.Qt.ItemIsEditable)
self.is_editing = True
def update_preview(self):
""" """
rule_text = self.preview.toPlainText()
if -1 != rule_text.find("features:"):
rule_text = rule_text[: rule_text.find("features:") + len("features:")]
rule_text += "\n"
else:
rule_text = rule_text.rstrip()
rule_text += "\n features:\n"
for o in iterate_tree(self):
feature, description, comment = (o.strip() for o in tuple(o.text(i) for i in range(3)))
rule_text += parse_node_for_feature(feature, description, comment, calc_item_depth(o))
# TODO(mike-hunhoff): we avoid circular update by disabling signals when updating
# the preview. Preferably we would refactor the code to avoid this
# in the first place.
# https://github.com/mandiant/capa/issues/1600
self.preview.blockSignals(True)
self.preview.setPlainText(rule_text)
self.preview.blockSignals(False)
# emit signal so views can update
self.updated.emit()
def load_custom_context_menu_invalid_index(self, pos):
""" """
actions = (("Remove all", (), self.slot_clear_all),)
menu = build_context_menu(self.parent(), actions)
menu.exec_(self.viewport().mapToGlobal(pos))
def load_custom_context_menu_feature(self, pos):
""" """
actions = (("Remove selection", (), self.slot_remove_selected),)
sub_actions = (
("and", ("- and:",), self.slot_nest_features),
("or", ("- or:",), self.slot_nest_features),
("not", ("- not:",), self.slot_nest_features),
("optional", ("- optional:",), self.slot_nest_features),
("basic block", ("- basic block:",), self.slot_nest_features),
("instruction", ("- instruction:",), self.slot_nest_features),
)
# build submenu with modify actions
sub_menu = build_context_menu(self.parent(), sub_actions)
sub_menu.setTitle(f"Nest feature{'' if len(tuple(self.get_features(selected=True))) == 1 else 's'}")
# build main menu with submenu + main actions
menu = build_context_menu(self.parent(), (sub_menu,) + actions)
menu.exec_(self.viewport().mapToGlobal(pos))
def load_custom_context_menu_expression(self, pos):
""" """
actions = (("Remove expression", (), self.slot_remove_selected),)
sub_actions = (
("and", ("- and:", self.itemAt(pos)), self.slot_edit_expression),
("or", ("- or:", self.itemAt(pos)), self.slot_edit_expression),
("not", ("- not:", self.itemAt(pos)), self.slot_edit_expression),
("optional", ("- optional:", self.itemAt(pos)), self.slot_edit_expression),
("basic block", ("- basic block:", self.itemAt(pos)), self.slot_edit_expression),
("instruction", ("- instruction:", self.itemAt(pos)), self.slot_edit_expression),
)
# build submenu with modify actions
sub_menu = build_context_menu(self.parent(), sub_actions)
sub_menu.setTitle("Modify")
# build main menu with submenu + main actions
menu = build_context_menu(self.parent(), (sub_menu,) + actions)
menu.exec_(self.viewport().mapToGlobal(pos))
def style_expression_node(self, o):
""" """
font = QtGui.QFont()
font.setBold(True)
o.setFont(CapaExplorerRulegenEditor.get_column_feature_index(), font)
def style_feature_node(self, o):
""" """
font = QtGui.QFont()
brush = QtGui.QBrush()
font.setFamily("Courier")
font.setWeight(QtGui.QFont.Medium)
brush.setColor(QtGui.QColor(*COLOR_GREEN_RGB))
o.setFont(CapaExplorerRulegenEditor.get_column_feature_index(), font)
o.setForeground(CapaExplorerRulegenEditor.get_column_feature_index(), brush)
def style_comment_node(self, o):
""" """
font = QtGui.QFont()
font.setBold(True)
font.setFamily("Courier")
o.setFont(CapaExplorerRulegenEditor.get_column_feature_index(), font)
def set_expression_node(self, o):
""" """
setattr(o, "capa_type", CapaExplorerRulegenEditor.get_node_type_expression())
self.style_expression_node(o)
def set_feature_node(self, o):
""" """
setattr(o, "capa_type", CapaExplorerRulegenEditor.get_node_type_feature())
o.setFlags(o.flags() & ~QtCore.Qt.ItemIsDropEnabled)
self.style_feature_node(o)
def set_comment_node(self, o):
""" """
setattr(o, "capa_type", CapaExplorerRulegenEditor.get_node_type_comment())
o.setFlags(o.flags() & ~QtCore.Qt.ItemIsDropEnabled)
self.style_comment_node(o)
def new_expression_node(self, parent, values=()):
""" """
o = QtWidgets.QTreeWidgetItem(parent)
self.set_expression_node(o)
for i, v in enumerate(values):
o.setText(i, v)
return o
def new_feature_node(self, parent, values=()):
""" """
o = QtWidgets.QTreeWidgetItem(parent)
self.set_feature_node(o)
for i, v in enumerate(values):
o.setText(i, v)
return o
def new_comment_node(self, parent, values=()):
""" """
o = QtWidgets.QTreeWidgetItem(parent)
self.set_comment_node(o)
for i, v in enumerate(values):
o.setText(i, v)
return o
def update_features(self, features):
""" """
if not self.invisibleRootItem().childCount():
# empty tree; add a default node
self.new_expression_node(self.invisibleRootItem(), ("- or:", ""))
# we don't want to add new features under the invisible root because capa rules should
# contain a single top-level node; this may not always be the case so we default to the last
# child node that was added to the invisible root
top_node = self.invisibleRootItem().child(self.invisibleRootItem().childCount() - 1)
# build feature counts
counted = list(zip(Counter(features).keys(), Counter(features).values()))
# single features
for k, _ in filter(lambda t: t[1] == 1, counted):
if isinstance(k, (capa.features.common.String,)):
value = f'"{capa.features.common.escape_string(k.get_value_str())}"'
else:
value = k.get_value_str()
self.new_feature_node(top_node, (f"- {k.name.lower()}: {value}", ""))
# n > 1 features
for k, v in filter(lambda t: t[1] > 1, counted):
if k.value:
if isinstance(k, (capa.features.common.String,)):
value = f'"{capa.features.common.escape_string(k.get_value_str())}"'
else:
value = k.get_value_str()
display = f"- count({k.name.lower()}({value})): {v}"
else:
display = f"- count({k.name.lower()}): {v}"
self.new_feature_node(top_node, (display, ""))
self.update_preview()
expand_tree(self.invisibleRootItem())
resize_columns_to_content(self.header())
def make_child_node_from_feature(self, parent, feature):
""" """
feature, comment, description = feature
# we need special handling for the "description" tag; meaning we don't add a new node but simply
# set the "description" column for the appropriate parent node
if feature.startswith("description:"):
if not parent:
# we shouldn't have description without a parent; do nothing
return None
# we don't add a new node for description; either set description column of parent's last child
# or the parent itself
if feature.startswith("description:"):
description = feature[len("description:") :].lstrip()
if parent.childCount():
parent.child(parent.childCount() - 1).setText(1, description)
else:
parent.setText(1, description)
return None
elif feature.startswith("- description:"):
if not parent:
# we shouldn't have a description without a parent; do nothing
return None
# we don't add a new node for description; set the description column of the parent instead
description = feature[len("- description:") :].lstrip()
parent.setText(1, description)
return None
node = QtWidgets.QTreeWidgetItem(parent)
# set node text to data parsed from feature
for idx, text in enumerate((feature, comment, description)):
node.setText(idx, text)
# we need to set our own type so we can control the GUI accordingly
if feature.startswith(("- and:", "- or:", "- not:", "- basic block:", "- instruction:", "- optional:")):
setattr(node, "capa_type", CapaExplorerRulegenEditor.get_node_type_expression())
elif feature.startswith("#"):
setattr(node, "capa_type", CapaExplorerRulegenEditor.get_node_type_comment())
else:
setattr(node, "capa_type", CapaExplorerRulegenEditor.get_node_type_feature())
# format the node based on its type
(self.set_expression_node, self.set_feature_node, self.set_comment_node)[node.capa_type](node)
parent.addChild(node)
return node
def load_features_from_yaml(self, rule_text, update_preview=False):
""" """
self.reset_view()
# check for lack of features block
if -1 == rule_text.find("features:"):
return
rule_features = rule_text[rule_text.find("features:") + len("features:") :].strip("\n")
if not rule_features:
# no features; nothing to do
return
# build tree from yaml text using stack-based algorithm to build parent -> child edges
stack = [self.invisibleRootItem()]
for line in rule_features.splitlines():
if not len(line.strip()):
continue
indent = calc_indent_from_line(line)
# we need to grow our stack to ensure proper parent -> child edges
if indent > len(stack):
stack.extend([None] * (indent - len(stack)))
# shave the stack; divide by 2 because even indent, add 1 to avoid shaving root node
stack[indent // 2 + 1 :] = []
# find our parent; should be last node in stack not None
parent = None
for o in stack[::-1]:
if o:
parent = o
break
node = self.make_child_node_from_feature(parent, parse_yaml_line(line.strip()))
# append our new node in case it's a parent for another node
if node:
stack.append(node)
if update_preview:
self.preview.blockSignals(True)
self.preview.setPlainText(rule_text)
self.preview.blockSignals(False)
expand_tree(self.invisibleRootItem())
def get_features(self, selected=False, ignore=()):
""" """
for feature in filter(
lambda o: o.capa_type
in (CapaExplorerRulegenEditor.get_node_type_feature(), CapaExplorerRulegenEditor.get_node_type_comment()),
tuple(iterate_tree(self)),
):
if feature in ignore:
continue
if selected and not feature.isSelected():
continue
yield feature
def get_expressions(self, selected=False, ignore=()):
""" """
for expression in filter(
lambda o: o.capa_type == CapaExplorerRulegenEditor.get_node_type_expression(), tuple(iterate_tree(self))
):
if expression in ignore:
continue
if selected and not expression.isSelected():
continue
yield expression
class CapaExplorerRulegenFeatures(QtWidgets.QTreeWidget):
def __init__(self, editor, parent=None):
""" """
super().__init__(parent)
self.parent_items = {}
self.editor = editor
self.setHeaderLabels(["Feature", "Address"])
self.setStyleSheet("QTreeView::item {padding-right: 15 px;padding-bottom: 2 px;}")
# configure view columns to auto-resize
for idx in range(2):
self.header().setSectionResizeMode(idx, QtWidgets.QHeaderView.Interactive)
self.setExpandsOnDoubleClick(False)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
# connect slots
self.itemDoubleClicked.connect(self.slot_item_double_clicked)
self.customContextMenuRequested.connect(self.slot_custom_context_menu_requested)
self.expanded.connect(self.slot_resize_columns_to_content)
self.collapsed.connect(self.slot_resize_columns_to_content)
self.reset_view()
@staticmethod
def get_column_feature_index():
""" """
return 0
@staticmethod
def get_column_address_index():
""" """
return 1
@staticmethod
def get_node_type_parent():
""" """
return 0
@staticmethod
def get_node_type_leaf():
""" """
return 1
def reset_view(self):
""" """
self.clear()
def slot_resize_columns_to_content(self):
""" """
resize_columns_to_content(self.header())
def slot_add_selected_features(self, action):
""" """
selected = [item.data(0, 0x100) for item in self.selectedItems()]
if selected:
self.editor.update_features(selected)
def slot_add_n_bytes_feature(self, action):
""" """
count = idaapi.ask_long(16, f"Enter number of bytes (1-{capa.features.common.MAX_BYTES_FEATURE_SIZE}):")
if count and 1 <= count <= capa.features.common.MAX_BYTES_FEATURE_SIZE:
item = self.selectedItems()[0].data(0, 0x100)
item.value = item.value[:count]
self.editor.update_features([item])
def slot_custom_context_menu_requested(self, pos):
""" """
actions = []
action_add_features_fmt = ""
selected_items_count = len(self.selectedItems())
if selected_items_count == 0:
return
if selected_items_count == 1:
action_add_features_fmt = "Add feature"
if isinstance(self.selectedItems()[0].data(0, 0x100), capa.features.common.Bytes):
actions.append(("Add n bytes...", (), self.slot_add_n_bytes_feature))
else:
action_add_features_fmt = f"Add {selected_items_count} features"
actions.append((action_add_features_fmt, (), self.slot_add_selected_features))
menu = build_context_menu(self.parent(), actions)
menu.exec_(self.viewport().mapToGlobal(pos))
def slot_item_double_clicked(self, o, column):
""" """
if column == CapaExplorerRulegenFeatures.get_column_address_index() and o.text(column):
idc.jumpto(int(o.text(column), 0x10))
elif o.capa_type == CapaExplorerRulegenFeatures.get_node_type_leaf():
self.editor.update_features([o.data(0, 0x100)])
def show_all_items(self):
""" """
for o in iterate_tree(self):
o.setHidden(False)
o.setExpanded(False)
def filter_items_by_text(self, text):
""" """
if text:
for o in iterate_tree(self):
data = o.data(0, 0x100)
if data:
to_match = data.get_value_str()
if not to_match or text.lower() not in to_match.lower():
if not o.isHidden():
o.setHidden(True)
continue
if o.isHidden():
o.setHidden(False)
if o.childCount() and not o.isExpanded():
o.setExpanded(True)
else:
self.show_all_items()
def filter_items_by_ea(self, min_ea, max_ea=None):
""" """
visited = []
def show_item_and_parents(_o):
"""iteratively show and expand an item and its' parents"""
while _o:
visited.append(_o)
if _o.isHidden():
_o.setHidden(False)
if _o.childCount() and not _o.isExpanded():
_o.setExpanded(True)
_o = _o.parent()
for o in iterate_tree(self):
if o in visited:
# save some cycles, only visit item once
continue
# read ea from "Address" column
o_ea = o.text(CapaExplorerRulegenFeatures.get_column_address_index())
if o_ea == "":
# ea may be empty, hide by default
if not o.isHidden():
o.setHidden(True)
continue
o_ea = int(o_ea, 16)
if max_ea is not None and min_ea <= o_ea <= max_ea:
show_item_and_parents(o)
elif o_ea == min_ea:
show_item_and_parents(o)
else:
# made it here, hide by default
if not o.isHidden():
o.setHidden(True)
# resize the view for UX
resize_columns_to_content(self.header())
def style_parent_node(self, o):
""" """
font = QtGui.QFont()
font.setBold(True)
o.setFont(CapaExplorerRulegenFeatures.get_column_feature_index(), font)
def style_leaf_node(self, o):
""" """
font = QtGui.QFont("Courier", weight=QtGui.QFont.Bold)
brush = QtGui.QBrush()
o.setFont(CapaExplorerRulegenFeatures.get_column_feature_index(), font)
o.setFont(CapaExplorerRulegenFeatures.get_column_address_index(), font)
brush.setColor(QtGui.QColor(*COLOR_GREEN_RGB))
o.setForeground(CapaExplorerRulegenFeatures.get_column_feature_index(), brush)
brush.setColor(QtGui.QColor(*COLOR_BLUE_RGB))
o.setForeground(CapaExplorerRulegenFeatures.get_column_address_index(), brush)
def set_parent_node(self, o):
""" """
o.setFlags(o.flags() & ~QtCore.Qt.ItemIsSelectable)
setattr(o, "capa_type", CapaExplorerRulegenFeatures.get_node_type_parent())
self.style_parent_node(o)
def set_leaf_node(self, o):
""" """
setattr(o, "capa_type", CapaExplorerRulegenFeatures.get_node_type_leaf())
self.style_leaf_node(o)
def new_parent_node(self, parent, data, feature=None):
""" """
o = QtWidgets.QTreeWidgetItem(parent)
self.set_parent_node(o)
for i, v in enumerate(data):
o.setText(i, v)
if feature:
o.setData(0, 0x100, feature)
return o
def new_leaf_node(self, parent, data, feature=None):
""" """
o = QtWidgets.QTreeWidgetItem(parent)
self.set_leaf_node(o)
for i, v in enumerate(data):
o.setText(i, v)
if feature:
o.setData(0, 0x100, feature)
return o
def load_features(self, file_features, func_features: Optional[dict] = None):
""" """
self.parse_features_for_tree(self.new_parent_node(self, ("File Scope",)), file_features)
if func_features:
self.parse_features_for_tree(self.new_parent_node(self, ("Function/Basic Block Scope",)), func_features)
resize_columns_to_content(self.header())
def parse_features_for_tree(self, parent, features):
""" """
self.parent_items = {}
def format_address(e):
if isinstance(e, AbsoluteVirtualAddress):
return f"{hex(int(e))}"
else:
return ""
def format_feature(feature):
""" """
name = feature.name.lower()
value = feature.get_value_str()
if isinstance(feature, (capa.features.common.String,)):
value = f'"{capa.features.common.escape_string(value)}"'
return f"{name}({value})"
for feature, addrs in sorted(features.items(), key=lambda k: sorted(k[1])):
if isinstance(feature, capa.features.basicblock.BasicBlock):
# filter basic blocks for now, we may want to add these back in some time
# in the future
continue
# level 0
if type(feature) not in self.parent_items:
self.parent_items[type(feature)] = self.new_parent_node(parent, (feature.name.lower(),))
# level 1
if feature not in self.parent_items:
if len(addrs) > 1:
self.parent_items[feature] = self.new_parent_node(
self.parent_items[type(feature)], (format_feature(feature),), feature=feature
)
else:
self.parent_items[feature] = self.new_leaf_node(
self.parent_items[type(feature)], (format_feature(feature),), feature=feature
)
# level n > 1
if len(addrs) > 1:
for addr in sorted(addrs):
self.new_leaf_node(
self.parent_items[feature], (format_feature(feature), format_address(addr)), feature=feature
)
else:
if addrs:
addr = addrs.pop()
else:
# some features may not have an address e.g. "format"
addr = _NoAddress()
for i, v in enumerate((format_feature(feature), format_address(addr))):
self.parent_items[feature].setText(i, v)
self.parent_items[feature].setData(0, 0x100, feature)
class CapaExplorerQtreeView(QtWidgets.QTreeView):
"""tree view used to display hierarchical capa results
view controls UI action responses and displays data from CapaExplorerDataModel
view does not modify CapaExplorerDataModel directly - data modifications should be implemented
in CapaExplorerDataModel
"""
def __init__(self, model, parent=None):
"""initialize view"""
super().__init__(parent)
self.setModel(model)
self.model = model
self.parent = parent
# control when we resize columns
self.should_resize_columns = True
# configure custom UI controls
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.setExpandsOnDoubleClick(False)
self.setSortingEnabled(True)
self.model.setDynamicSortFilter(False)
# configure view columns to auto-resize
for idx in range(CapaExplorerDataModel.COLUMN_COUNT):
self.header().setSectionResizeMode(idx, QtWidgets.QHeaderView.Interactive)
# disable stretch to enable horizontal scroll for last column, when needed
self.header().setStretchLastSection(False)
# connect slots to resize columns when expanded or collapsed
self.expanded.connect(self.slot_resize_columns_to_content)
self.collapsed.connect(self.slot_resize_columns_to_content)
# connect slots
self.customContextMenuRequested.connect(self.slot_custom_context_menu_requested)
self.doubleClicked.connect(self.slot_double_click)
self.setStyleSheet("QTreeView::item {padding-right: 15 px;padding-bottom: 2 px;}")
def reset_ui(self, should_sort=True):
"""reset user interface changes
called when view should reset UI display e.g. expand items, resize columns
@param should_sort: True, sort results after reset, False don't sort results after reset
"""
if should_sort:
self.sortByColumn(CapaExplorerDataModel.COLUMN_INDEX_RULE_INFORMATION, QtCore.Qt.AscendingOrder)
self.should_resize_columns = False
self.expandToDepth(0)
self.should_resize_columns = True
self.slot_resize_columns_to_content()
def slot_resize_columns_to_content(self):
"""reset view columns to contents"""
if self.should_resize_columns:
resize_columns_to_content(self.header())
def map_index_to_source_item(self, model_index):
"""map proxy model index to source model item
@param model_index: QModelIndex
@retval QObject
"""
# assume that self.model here is either:
# - CapaExplorerDataModel, or
# - QSortFilterProxyModel subclass
#
# The ProxyModels may be chained,
# so keep resolving the index the CapaExplorerDataModel.
model = self.model
while not isinstance(model, CapaExplorerDataModel):
if not model_index.isValid():
raise ValueError("invalid index")
model_index = model.mapToSource(model_index)
model = model.sourceModel()
if not model_index.isValid():
raise ValueError("invalid index")
return model_index.internalPointer()
def send_data_to_clipboard(self, data):
"""copy data to the clipboard
@param data: data to be copied
"""
clip = QtWidgets.QApplication.clipboard()
clip.clear(mode=clip.Clipboard)
clip.setText(data, mode=clip.Clipboard)
def new_action(self, display, data, slot):
"""create action for context menu
@param display: text displayed to user in context menu
@param data: data passed to slot
@param slot: slot to connect
@retval QAction
"""
action = QtWidgets.QAction(display, self.parent)
action.setData(data)
action.triggered.connect(lambda checked: slot(action))
return action
def load_default_context_menu_actions(self, data):
"""yield actions specific to function custom context menu
@param data: tuple
@yield QAction
"""
default_actions = (
("Copy column", data, self.slot_copy_column),
("Copy row", data, self.slot_copy_row),
)
# add default actions
for action in default_actions:
yield self.new_action(*action)
def load_function_context_menu_actions(self, data):
"""yield actions specific to function custom context menu
@param data: tuple
@yield QAction
"""
function_actions = (("Rename function", data, self.slot_rename_function),)
# add function actions
for action in function_actions:
yield self.new_action(*action)
# add default actions
yield from self.load_default_context_menu_actions(data)
def load_default_context_menu(self, pos, item, model_index):
"""create default custom context menu
creates custom context menu containing default actions
@param pos: cursor position
@param item: CapaExplorerDataItem
@param model_index: QModelIndex
@retval QMenu
"""
menu = QtWidgets.QMenu()
for action in self.load_default_context_menu_actions((pos, item, model_index)):
menu.addAction(action)
return menu
def load_function_item_context_menu(self, pos, item, model_index):
"""create function custom context menu
creates custom context menu with both default actions and function actions
@param pos: cursor position
@param item: CapaExplorerDataItem
@param model_index: QModelIndex
@retval QMenu
"""
menu = QtWidgets.QMenu()
for action in self.load_function_context_menu_actions((pos, item, model_index)):
menu.addAction(action)
return menu
def show_custom_context_menu(self, menu, pos):
"""display custom context menu in view
@param menu: QMenu to display
@param pos: cursor position
"""
if menu:
menu.exec_(self.viewport().mapToGlobal(pos))
def slot_copy_column(self, action):
"""slot connected to custom context menu
allows user to select a column and copy the data to clipboard
@param action: QAction
"""
_, item, model_index = action.data()
self.send_data_to_clipboard(item.data(model_index.column()))
def slot_copy_row(self, action):
"""slot connected to custom context menu
allows user to select a row and copy the space-delimited data to clipboard
@param action: QAction
"""
_, item, _ = action.data()
self.send_data_to_clipboard(str(item))
def slot_rename_function(self, action):
"""slot connected to custom context menu
allows user to select a edit a function name and push changes to IDA
@param action: QAction
"""
_, item, model_index = action.data()
# make item temporary edit, reset after user is finished
item.setIsEditable(True)
self.edit(model_index)
item.setIsEditable(False)
def slot_custom_context_menu_requested(self, pos):
"""slot connected to custom context menu request
displays custom context menu to user containing action relevant to the item selected
@param pos: cursor position
"""
model_index = self.indexAt(pos)
if not model_index.isValid():
return
item = self.map_index_to_source_item(model_index)
column = model_index.column()
menu = None
if CapaExplorerDataModel.COLUMN_INDEX_RULE_INFORMATION == column and isinstance(item, CapaExplorerFunctionItem):
# user hovered function item
menu = self.load_function_item_context_menu(pos, item, model_index)
else:
# user hovered default item
menu = self.load_default_context_menu(pos, item, model_index)
# show custom context menu at view position
self.show_custom_context_menu(menu, pos)
def slot_double_click(self, model_index):
"""slot connected to double-click event
if address column clicked, navigate IDA to address, else un/expand item clicked
@param model_index: QModelIndex
"""
if not model_index.isValid():
return
item = self.map_index_to_source_item(model_index)
column = model_index.column()
if CapaExplorerDataModel.COLUMN_INDEX_VIRTUAL_ADDRESS == column and item.location:
# user double-clicked virtual address column - navigate IDA to address
idc.jumpto(item.location)
if CapaExplorerDataModel.COLUMN_INDEX_RULE_INFORMATION == column:
# user double-clicked information column - un/expand
self.collapse(model_index) if self.isExpanded(model_index) else self.expand(model_index)