Skip to content
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
### capa Explorer Web

### capa Explorer IDA Pro plugin
- ida plugin: add a font explorer in Settings with live preview #2570

### Development
- ci: use explicit and per job permissions @mike-hunhoff #3002
Expand Down Expand Up @@ -163,7 +164,7 @@ Additionally a Binary Ninja bug has been fixed. Released binaries now include AR

### Bug Fixes

- binja: fix a crash during feature extraction when the MLIL is unavailable @xusheng6 #2714
- binja: fix a crash during feature extraction when the MLIL is unavailable @xusheng6 #2714

### capa Explorer Web

Expand Down
112 changes: 99 additions & 13 deletions capa/ida/plugin/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,34 @@
CAPA_SETTINGS_RULEGEN_AUTHOR = "rulegen_author"
CAPA_SETTINGS_RULEGEN_SCOPE = "rulegen_scope"
CAPA_SETTINGS_ANALYZE = "analyze"
CAPA_SETTINGS_FONT = "font"


CAPA_OFFICIAL_RULESET_URL = f"https://github.com/mandiant/capa-rules/releases/tag/v{capa.version.__version__}"


def get_configured_font() -> QtGui.QFont:
"""return the saved font or fall back to the system fixed font"""
font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont)
font_str = settings.user.get(CAPA_SETTINGS_FONT, "")
if font_str:
font.fromString(font_str)
return font


def get_scaled_ui_font(font: QtGui.QFont) -> QtGui.QFont:
"""return the default UI font scaled to the configured point size"""
ui_font = QtGui.QFont()
if font.pointSize() > 0:
ui_font.setPointSize(font.pointSize())
return ui_font


def get_default_ui_font() -> QtGui.QFont:
"""return the default UI font without any user scaling"""
return QtGui.QFont()


CAPA_RULESET_DOC_URL = "https://github.com/mandiant/capa/blob/master/doc/rules.md"


Expand Down Expand Up @@ -117,10 +142,12 @@ def mouseReleaseEvent(self, e):


class CapaSettingsInputDialog(QtWidgets.QDialog):
def __init__(self, title, parent=None):
def __init__(self, title, parent=None, on_font_changed=None):
""" """
super().__init__(parent)

self.on_font_changed = on_font_changed

self.setWindowTitle(title)
self.setMinimumWidth(500)
self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
Expand All @@ -130,9 +157,11 @@ def __init__(self, title, parent=None):
self.edit_rule_scope = QtWidgets.QComboBox()
self.edit_rules_link = QtWidgets.QLabel()
self.edit_analyze = QtWidgets.QComboBox()
self.btn_font = QtWidgets.QPushButton("Font")
self.btn_delete_results = QtWidgets.QPushButton(
self.style().standardIcon(QtWidgets.QStyle.SP_BrowserStop), "Delete cached capa results"
)
self.font = get_configured_font()

self.edit_rules_link.setText(
f'<a href="{CAPA_OFFICIAL_RULESET_URL}">Download and extract official capa rules</a>'
Expand All @@ -154,6 +183,7 @@ def __init__(self, title, parent=None):
layout.addRow("", self.edit_rules_link)

layout.addRow("Plugin start option", self.edit_analyze)
layout.addRow("Explorer font", self.btn_font)
if capa.ida.helpers.idb_contains_cached_results():
self.btn_delete_results.clicked.connect(capa.ida.helpers.delete_cached_results)
self.btn_delete_results.clicked.connect(lambda state: self.btn_delete_results.setEnabled(False))
Expand All @@ -167,16 +197,33 @@ def __init__(self, title, parent=None):

layout.addWidget(buttons)

self.btn_font.clicked.connect(self.select_font)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)

def select_font(self):
"""launch the font dialog"""
original_font = QtGui.QFont(self.font)
dialog = QtWidgets.QFontDialog(self.font, self)
dialog.setWindowTitle("Select Plugin Font")
if self.on_font_changed:
dialog.currentFontChanged.connect(self.on_font_changed)

if dialog.exec_():
self.font = dialog.currentFont()
if self.on_font_changed:
self.on_font_changed(self.font)
elif self.on_font_changed:
self.on_font_changed(original_font)

def get_values(self):
""" """
return (
self.edit_rule_path.text(),
self.edit_rule_author.text(),
self.edit_rule_scope.currentText(),
self.edit_analyze.currentIndex(),
self.font.toString(),
)


Expand Down Expand Up @@ -228,6 +275,8 @@ def __init__(self, name: str, option=Options.NO_ANALYSIS):
self.view_rulegen_preview: CapaExplorerRulegenPreview
self.view_rulegen_features: CapaExplorerRulegenFeatures
self.view_rulegen_editor: CapaExplorerRulegenEditor
self.view_rulegen_preview_label: QtWidgets.QLabel
self.view_rulegen_editor_label: QtWidgets.QLabel
self.view_rulegen_header_label: QtWidgets.QLabel
self.view_rulegen_search: QtWidgets.QLineEdit
self.view_rulegen_limit_features_by_ea: QtWidgets.QCheckBox
Expand Down Expand Up @@ -304,6 +353,32 @@ def load_interface(self):

# load parent view
self.load_view_parent()
self.load_font()

def load_font(self):
"""load the user-configured font or fall back to the system fixed font"""
self.update_fonts(get_configured_font())

def update_fonts(self, font: QtGui.QFont):
"""propagate the selected font throughout the plugin UI"""
expanded_items = []
ui_font = get_scaled_ui_font(font)
if hasattr(self, "view_tree") and self.view_tree:
expanded_items = self.view_tree.get_expanded_source_items()

for component_name in (
"model_data",
"view_tree",
"view_rulegen_preview",
"view_rulegen_editor",
"view_rulegen_features",
):
component = getattr(self, component_name, None)
if component:
component.update_font(font, ui_font)

if hasattr(self, "view_tree") and self.view_tree:
self.view_tree.restore_expanded_source_items(expanded_items)

def load_view_tabs(self):
"""load tabs"""
Expand Down Expand Up @@ -333,6 +408,7 @@ def load_view_status_label(self):
label = QtWidgets.QLabel()
label.setAlignment(QtCore.Qt.AlignLeft)
label.setText(status)
label.setFont(get_default_ui_font())

self.view_status_label_rulegen_cache = status
self.view_status_label_analysis_cache = status
Expand Down Expand Up @@ -368,6 +444,7 @@ def load_view_search_bar(self):
"""load the search bar control"""
line = QtWidgets.QLineEdit()
line.setPlaceholderText("search...")
line.setFont(get_default_ui_font())
line.textChanged.connect(self.slot_limit_results_to_search)

self.view_search_bar = line
Expand Down Expand Up @@ -418,17 +495,16 @@ def load_view_rulegen_tab(self):

font = QtGui.QFont()
font.setBold(True)
font.setPointSize(11)

label1 = QtWidgets.QLabel()
label1.setAlignment(QtCore.Qt.AlignLeft)
label1.setText("Preview")
label1.setFont(font)
self.view_rulegen_preview_label = QtWidgets.QLabel()
self.view_rulegen_preview_label.setAlignment(QtCore.Qt.AlignLeft)
self.view_rulegen_preview_label.setText("Preview")
self.view_rulegen_preview_label.setFont(font)

label2 = QtWidgets.QLabel()
label2.setAlignment(QtCore.Qt.AlignLeft)
label2.setText("Editor")
label2.setFont(font)
self.view_rulegen_editor_label = QtWidgets.QLabel()
self.view_rulegen_editor_label.setAlignment(QtCore.Qt.AlignLeft)
self.view_rulegen_editor_label.setText("Editor")
self.view_rulegen_editor_label.setFont(font)

self.view_rulegen_limit_features_by_ea = QtWidgets.QCheckBox("Limit features to current disassembly address")
self.view_rulegen_limit_features_by_ea.setChecked(False)
Expand All @@ -437,10 +513,12 @@ def load_view_rulegen_tab(self):
self.view_rulegen_status_label = QtWidgets.QLabel()
self.view_rulegen_status_label.setAlignment(QtCore.Qt.AlignLeft)
self.view_rulegen_status_label.setText("")
self.view_rulegen_status_label.setFont(get_default_ui_font())

self.view_rulegen_search = QtWidgets.QLineEdit()
self.view_rulegen_search.setPlaceholderText("search...")
self.view_rulegen_search.setClearButtonEnabled(True)
self.view_rulegen_search.setFont(get_default_ui_font())
self.view_rulegen_search.textChanged.connect(self.slot_limit_rulegen_features_to_search)

self.view_rulegen_header_label = QtWidgets.QLabel()
Expand All @@ -457,10 +535,10 @@ def load_view_rulegen_tab(self):

self.set_rulegen_preview_border_neutral()

layout1.addWidget(label1)
layout1.addWidget(self.view_rulegen_preview_label)
layout1.addWidget(self.view_rulegen_preview, 45)
layout1.addWidget(self.view_rulegen_status_label)
layout3.addWidget(label2)
layout3.addWidget(self.view_rulegen_editor_label)
layout3.addWidget(self.view_rulegen_editor, 65)

layout2.addWidget(self.view_rulegen_header_label)
Expand Down Expand Up @@ -1301,14 +1379,22 @@ def slot_save(self):

def slot_settings(self):
""" """
dialog = CapaSettingsInputDialog("capa explorer settings", parent=self.parent)
original_font = get_configured_font()

dialog = CapaSettingsInputDialog(
"capa explorer settings", parent=self.parent, on_font_changed=self.update_fonts
)
if dialog.exec_():
(
settings.user[CAPA_SETTINGS_RULE_PATH],
settings.user[CAPA_SETTINGS_RULEGEN_AUTHOR],
settings.user[CAPA_SETTINGS_RULEGEN_SCOPE],
settings.user[CAPA_SETTINGS_ANALYZE],
settings.user[CAPA_SETTINGS_FONT],
) = dialog.get_values()
self.load_font()
else:
self.update_fonts(original_font)

def save_program_analysis(self):
""" """
Expand Down
23 changes: 21 additions & 2 deletions capa/ida/plugin/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ def __init__(self, parent=None):
super().__init__(parent)
# root node does not have parent, contains header columns
self.root_node = CapaExplorerDataItem(None, ["Rule Information", "Address", "Details"])
self.current_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont)
self.current_ui_font = QtGui.QFont()

def update_font(self, font: QtGui.QFont, ui_font: Optional[QtGui.QFont] = None):
"""update the font used to render items"""
self.beginResetModel()
self.current_font = font
self.current_ui_font = QtGui.QFont(self.current_ui_font if ui_font is None else ui_font)
self.endResetModel()

def reset(self):
"""reset UI elements (e.g. checkboxes, IDA color highlights)
Expand Down Expand Up @@ -134,7 +143,8 @@ def data(self, model_index, role):
CapaExplorerDataModel.COLUMN_INDEX_DETAILS,
):
# set font for virtual address and details columns
font = QtGui.QFont("Courier", weight=QtGui.QFont.Medium)
font = QtGui.QFont(self.current_font)
font.setWeight(QtGui.QFont.Medium)
if column == CapaExplorerDataModel.COLUMN_INDEX_VIRTUAL_ADDRESS:
font.setBold(True)
return font
Expand All @@ -156,7 +166,7 @@ def data(self, model_index, role):
and column == CapaExplorerDataModel.COLUMN_INDEX_RULE_INFORMATION
):
# set bold font for important items
font = QtGui.QFont()
font = QtGui.QFont(self.current_ui_font)
font.setBold(True)
return font

Expand Down Expand Up @@ -244,6 +254,15 @@ def parent(self, model_index):

return self.createIndex(parent.row(), 0, parent)

def index_from_item(self, item, column=0):
"""return the model index for the given item"""
if item is None or item == self.root_node:
return QtCore.QModelIndex()

parent = item.parent()
parent_index = self.index_from_item(parent, 0)
return self.index(item.row(), column, parent_index)

def iterateChildrenIndexFromRootIndex(self, model_index, ignore_root=True):
"""depth-first traversal of child nodes

Expand Down
Loading
Loading