diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a44ec80..12d8eb383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/capa/ida/plugin/form.py b/capa/ida/plugin/form.py index 8c076cc68..20f019c38 100644 --- a/capa/ida/plugin/form.py +++ b/capa/ida/plugin/form.py @@ -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" @@ -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) @@ -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'Download and extract official capa rules' @@ -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)) @@ -167,9 +197,25 @@ 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 ( @@ -177,6 +223,7 @@ def get_values(self): self.edit_rule_author.text(), self.edit_rule_scope.currentText(), self.edit_analyze.currentIndex(), + self.font.toString(), ) @@ -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 @@ -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""" @@ -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 @@ -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 @@ -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) @@ -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() @@ -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) @@ -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): """ """ diff --git a/capa/ida/plugin/model.py b/capa/ida/plugin/model.py index c5e510ba8..be103b347 100644 --- a/capa/ida/plugin/model.py +++ b/capa/ida/plugin/model.py @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/capa/ida/plugin/view.py b/capa/ida/plugin/view.py index 4c21de1c9..6c2f666e8 100644 --- a/capa/ida/plugin/view.py +++ b/capa/ida/plugin/view.py @@ -175,6 +175,18 @@ def resize_columns_to_content(header): header.resizeSection(0, MAX_SECTION_SIZE) +def clone_ui_font(ui_font: Optional[QtGui.QFont]) -> QtGui.QFont: + """return a copy of the given UI font or the default UI font""" + return QtGui.QFont() if ui_font is None else QtGui.QFont(ui_font) + + +def get_bold_widget_font(widget: QtWidgets.QWidget) -> QtGui.QFont: + """return the widget font with bold enabled""" + font = QtGui.QFont(widget.font()) + font.setBold(True) + return font + + class CapaExplorerRulegenPreview(QtWidgets.QTextEdit): INDENT = " " * 2 @@ -182,11 +194,16 @@ def __init__(self, parent=None): """ """ super().__init__(parent) - self.setFont(QtGui.QFont("Courier", weight=QtGui.QFont.Bold)) + self.current_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont) + self.setFont(QtGui.QFont(self.current_font)) self.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) self.setAcceptRichText(False) + def update_font(self, font: QtGui.QFont, ui_font: Optional[QtGui.QFont] = None): + self.current_font = font + self.setFont(QtGui.QFont(self.current_font)) + def reset_view(self): """ """ self.clear() @@ -347,6 +364,7 @@ def __init__(self, preview, parent=None): self.reset_view() self.is_editing = False + self.current_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont) @staticmethod def get_column_feature_index(): @@ -498,6 +516,21 @@ def slot_item_double_clicked(self, o, column): o.setFlags(o.flags() & ~QtCore.Qt.ItemIsEditable) self.is_editing = True + def update_font(self, font: QtGui.QFont, ui_font: Optional[QtGui.QFont] = None): + """apply a new font to the editor and restyle existing nodes""" + self.current_font = font + ui_font = clone_ui_font(ui_font) + self.setFont(ui_font) + self.header().setFont(ui_font) + for node in iterate_tree(self): + if getattr(node, "capa_type", None) == CapaExplorerRulegenEditor.get_node_type_expression(): + self.style_expression_node(node) + elif getattr(node, "capa_type", None) == CapaExplorerRulegenEditor.get_node_type_feature(): + self.style_feature_node(node) + elif getattr(node, "capa_type", None) == CapaExplorerRulegenEditor.get_node_type_comment(): + self.style_comment_node(node) + self.slot_resize_columns_to_content() + def update_preview(self): """ """ rule_text = self.preview.toPlainText() @@ -577,17 +610,15 @@ def load_custom_context_menu_expression(self, pos): def style_expression_node(self, o): """ """ - font = QtGui.QFont() - font.setBold(True) + font = get_bold_widget_font(self) o.setFont(CapaExplorerRulegenEditor.get_column_feature_index(), font) def style_feature_node(self, o): """ """ - font = QtGui.QFont() + font = QtGui.QFont(self.current_font) brush = QtGui.QBrush() - font.setFamily("Courier") font.setWeight(QtGui.QFont.Medium) brush.setColor(QtGui.QColor(*COLOR_GREEN_RGB)) @@ -596,9 +627,8 @@ def style_feature_node(self, o): def style_comment_node(self, o): """ """ - font = QtGui.QFont() + font = QtGui.QFont(self.current_font) font.setBold(True) - font.setFamily("Courier") o.setFont(CapaExplorerRulegenEditor.get_column_feature_index(), font) @@ -819,6 +849,7 @@ def __init__(self, editor, parent=None): self.parent_items = {} self.editor = editor + self.current_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont) self.setHeaderLabels(["Feature", "Address"]) self.setStyleSheet("QTreeView::item {padding-right: 15 px;padding-bottom: 2 px;}") @@ -977,14 +1008,14 @@ def show_item_and_parents(_o): def style_parent_node(self, o): """ """ - font = QtGui.QFont() - font.setBold(True) + font = get_bold_widget_font(self) o.setFont(CapaExplorerRulegenFeatures.get_column_feature_index(), font) def style_leaf_node(self, o): """ """ - font = QtGui.QFont("Courier", weight=QtGui.QFont.Bold) + font = QtGui.QFont(self.current_font) + font.setBold(True) brush = QtGui.QBrush() o.setFont(CapaExplorerRulegenFeatures.get_column_feature_index(), font) @@ -996,6 +1027,19 @@ def style_leaf_node(self, o): brush.setColor(QtGui.QColor(*COLOR_BLUE_RGB)) o.setForeground(CapaExplorerRulegenFeatures.get_column_address_index(), brush) + def update_font(self, font: QtGui.QFont, ui_font: Optional[QtGui.QFont] = None): + """apply a new font to the feature tree and restyle nodes""" + self.current_font = font + ui_font = clone_ui_font(ui_font) + self.setFont(ui_font) + self.header().setFont(ui_font) + for node in iterate_tree(self): + if getattr(node, "capa_type", None) == CapaExplorerRulegenFeatures.get_node_type_parent(): + self.style_parent_node(node) + elif getattr(node, "capa_type", None) == CapaExplorerRulegenFeatures.get_node_type_leaf(): + self.style_leaf_node(node) + self.slot_resize_columns_to_content() + def set_parent_node(self, o): """ """ o.setFlags(o.flags() & ~QtCore.Qt.ItemIsSelectable) @@ -1138,6 +1182,62 @@ def __init__(self, model, parent=None): self.setStyleSheet("QTreeView::item {padding-right: 15 px;padding-bottom: 2 px;}") + def update_font(self, font: QtGui.QFont, ui_font: Optional[QtGui.QFont] = None): + """apply a new font to the tree view and its header""" + ui_font = clone_ui_font(ui_font) + self.setFont(ui_font) + + def iter_model_indexes(self, parent=None): + """yield all indexes in the current model""" + if parent is None: + parent = QtCore.QModelIndex() + + for row in range(self.model.rowCount(parent)): + model_index = self.model.index(row, 0, parent) + if not model_index.isValid(): + continue + yield model_index + yield from self.iter_model_indexes(model_index) + + def get_expanded_source_items(self): + """capture expanded items before a model reset""" + expanded = [] + for model_index in self.iter_model_indexes(): + if self.isExpanded(model_index): + expanded.append(self.map_index_to_source_item(model_index)) + return expanded + + def map_source_index_to_proxy(self, source_index): + """map a source-model index through the proxy chain""" + if not source_index.isValid(): + return QtCore.QModelIndex() + + models = [] + model = self.model + while model is not None and not isinstance(model, CapaExplorerDataModel): + models.append(model) + model = model.sourceModel() + + proxy_index = source_index + for proxy_model in reversed(models): + proxy_index = proxy_model.mapFromSource(proxy_index) + if not proxy_index.isValid(): + break + + return proxy_index + + def restore_expanded_source_items(self, items): + """restore expanded items after a model reset""" + model = self.model + while not isinstance(model, CapaExplorerDataModel): + model = model.sourceModel() + + for item in items: + source_index = model.index_from_item(item) + proxy_index = self.map_source_index_to_proxy(source_index) + if proxy_index.isValid(): + self.expand(proxy_index) + def reset_ui(self, should_sort=True): """reset user interface changes