diff --git a/.bumpversion.toml b/.bumpversion.toml new file mode 100644 index 0000000000..221cd9b81d --- /dev/null +++ b/.bumpversion.toml @@ -0,0 +1,22 @@ +[tool.bumpversion] +current_version = "9.2.1" + +[[tool.bumpversion.files]] +filename = "capa/version.py" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' + +[[tool.bumpversion.files]] +filename = "capa/ida/plugin/ida-plugin.json" +search = '"version": "{current_version}"' +replace = '"version": "{new_version}"' + +[[tool.bumpversion.files]] +filename = "capa/ida/plugin/ida-plugin.json" +search = '"flare-capa=={current_version}"' +replace = '"flare-capa=={new_version}"' + +[[tool.bumpversion.files]] +filename = "CHANGELOG.md" +search = "v{current_version}...master" +replace = "{current_version}...{new_version}" diff --git a/.gitignore b/.gitignore index 997cef4cc9..cef85dbae6 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,7 @@ scripts/perf/*.zip */.DS_Store Pipfile Pipfile.lock +uv.lock /cache/ .github/binja/binaryninja .github/binja/download_headless.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c16edf334..a38839e935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### Breaking Changes -### New Rules (19) +### New Rules (21) - anti-analysis/anti-vm/vm-detection/detect-mouse-movement-via-activity-checks-on-windows tevajdr@gmail.com - nursery/create-executable-heap moritz.raabe@mandiant.com @@ -27,17 +27,25 @@ - host-interaction/network/enumerate-tcp-connections-via-wmi-com-api jakubjozwiak@google.com - host-interaction/network/routing-table/create-routing-table-entry jakubjozwiak@google.com - host-interaction/network/routing-table/get-routing-table michael.hunhoff@mandiant.com +- host-interaction/file-system/use-io_uring-io-interface-on-linux jakubjozwiak@google.com +- collection/keylog/log-keystrokes-via-direct-input zeze-zeze - ### Bug Fixes +- binja: fix a crash during feature extraction when the MLIL is unavailable @xusheng6 #2714 + ### capa Explorer Web ### capa Explorer IDA Pro plugin +- add `ida-plugin.json` for inclusion in the IDA Pro plugin repository @williballenthin +- ida plugin: add Qt compatibility layer for PyQt5 and PySide6 support @williballenthin #2707 + ### Development - ci: remove redundant "test_run" action from build workflow @mike-hunhoff #2692 +- dev: add bumpmyversion to bump and sync versions across the project @mr-tz ### Raw diffs - [capa v9.2.1...master](https://github.com/mandiant/capa/compare/v9.2.1...master) diff --git a/capa/features/extractors/binja/function.py b/capa/features/extractors/binja/function.py index 707c572e93..b52c80582a 100644 --- a/capa/features/extractors/binja/function.py +++ b/capa/features/extractors/binja/function.py @@ -19,7 +19,6 @@ Function, BinaryView, SymbolType, - ILException, RegisterValueType, VariableSourceType, LowLevelILOperation, @@ -192,9 +191,8 @@ def extract_stackstring(fh: FunctionHandle): if bv is None: return - try: - mlil = func.mlil - except ILException: + mlil = func.mlil + if mlil is None: return for block in mlil.basic_blocks: diff --git a/capa/ida/plugin/extractor.py b/capa/ida/plugin/extractor.py index a5b86f4ee1..a2f24f22b1 100644 --- a/capa/ida/plugin/extractor.py +++ b/capa/ida/plugin/extractor.py @@ -14,9 +14,9 @@ import ida_kernwin -from PyQt5 import QtCore from capa.ida.plugin.error import UserCancelledError +from capa.ida.plugin.qt_compat import QtCore, Signal from capa.features.extractors.ida.extractor import IdaFeatureExtractor from capa.features.extractors.base_extractor import FunctionHandle @@ -24,7 +24,7 @@ class CapaExplorerProgressIndicator(QtCore.QObject): """implement progress signal, used during feature extraction""" - progress = QtCore.pyqtSignal(str) + progress = Signal(str) def update(self, text): """emit progress update diff --git a/capa/ida/plugin/form.py b/capa/ida/plugin/form.py index 36d104c894..800453bbfa 100644 --- a/capa/ida/plugin/form.py +++ b/capa/ida/plugin/form.py @@ -23,7 +23,6 @@ import idaapi import ida_kernwin import ida_settings -from PyQt5 import QtGui, QtCore, QtWidgets import capa.main import capa.rules @@ -51,6 +50,7 @@ from capa.ida.plugin.model import CapaExplorerDataModel from capa.ida.plugin.proxy import CapaExplorerRangeProxyModel, CapaExplorerSearchProxyModel from capa.ida.plugin.extractor import CapaExplorerFeatureExtractor +from capa.ida.plugin.qt_compat import QtGui, QtCore, QtWidgets from capa.features.extractors.base_extractor import FunctionHandle logger = logging.getLogger(__name__) @@ -1358,7 +1358,7 @@ def slot_checkbox_limit_by_changed(self, state): @param state: checked state """ - if state == QtCore.Qt.Checked: + if state: self.limit_results_to_function(idaapi.get_func(idaapi.get_screen_ea())) else: self.range_model_proxy.reset_address_range_filter() @@ -1367,7 +1367,7 @@ def slot_checkbox_limit_by_changed(self, state): def slot_checkbox_limit_features_by_ea(self, state): """ """ - if state == QtCore.Qt.Checked: + if state: self.view_rulegen_features.filter_items_by_ea(idaapi.get_screen_ea()) else: self.view_rulegen_features.show_all_items() diff --git a/capa/ida/plugin/ida-plugin.json b/capa/ida/plugin/ida-plugin.json new file mode 100644 index 0000000000..c63fd80e54 --- /dev/null +++ b/capa/ida/plugin/ida-plugin.json @@ -0,0 +1,38 @@ +{ + "IDAMetadataDescriptorVersion": 1, + "plugin": { + "name": "capa", + "entryPoint": "capa_explorer.py", + "version": "9.2.1", + "idaVersions": ">=7.4", + "description": "Identify capabilities in executable files using FLARE's capa framework", + "license": "Apache-2.0", + "categories": [ + "malware-analysis", + "api-scripting-and-automation", + "ui-ux-and-visualization" + ], + "pythonDependencies": ["flare-capa==9.2.1"], + "urls": { + "repository": "https://github.com/mandiant/capa" + }, + "authors": [ + {"name": "Willi Ballenthin", "email": "wballenthin@hex-rays.com"}, + {"name": "Moritz Raabe", "email": "moritzraabe@google.com"}, + {"name": "Mike Hunhoff", "email": "mike.hunhoff@gmail.com"}, + {"name": "Yacine Elhamer", "email": "elhamer.yacine@gmail.com"} + ], + "keywords": [ + "capability-detection", + "malware-analysis", + "behavior-analysis", + "reverse-engineering", + "att&ck", + "rule-engine", + "feature-extraction", + "yara-like-rules", + "static-analysis", + "dynamic-analysis" + ] + } +} diff --git a/capa/ida/plugin/item.py b/capa/ida/plugin/item.py index c8d3bdab7e..0510d5f971 100644 --- a/capa/ida/plugin/item.py +++ b/capa/ida/plugin/item.py @@ -18,10 +18,10 @@ import idc import idaapi -from PyQt5 import QtCore import capa.ida.helpers from capa.features.address import Address, FileOffsetAddress, AbsoluteVirtualAddress +from capa.ida.plugin.qt_compat import QtCore, qt_get_item_flag_tristate def info_to_name(display): @@ -55,7 +55,7 @@ def __init__(self, parent: Optional["CapaExplorerDataItem"], data: list[str], ca self.flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable if self._can_check: - self.flags = self.flags | QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsTristate + self.flags = self.flags | QtCore.Qt.ItemIsUserCheckable | qt_get_item_flag_tristate() if self.pred: self.pred.appendChild(self) diff --git a/capa/ida/plugin/model.py b/capa/ida/plugin/model.py index 405718a15e..046dc1ea3f 100644 --- a/capa/ida/plugin/model.py +++ b/capa/ida/plugin/model.py @@ -18,7 +18,6 @@ import idc import idaapi -from PyQt5 import QtGui, QtCore import capa.rules import capa.ida.helpers @@ -42,6 +41,7 @@ CapaExplorerInstructionViewItem, ) from capa.features.address import Address, AbsoluteVirtualAddress +from capa.ida.plugin.qt_compat import QtGui, QtCore # default highlight color used in IDA window DEFAULT_HIGHLIGHT = 0xE6C700 @@ -269,7 +269,7 @@ def iterateChildrenIndexFromRootIndex(self, model_index, ignore_root=True): visited.add(child_index) for idx in range(self.rowCount(child_index)): - stack.append(child_index.child(idx, 0)) + stack.append(self.index(idx, 0, child_index)) def reset_ida_highlighting(self, item, checked): """reset IDA highlight for item diff --git a/capa/ida/plugin/proxy.py b/capa/ida/plugin/proxy.py index e8b452103b..d76b895686 100644 --- a/capa/ida/plugin/proxy.py +++ b/capa/ida/plugin/proxy.py @@ -12,10 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from PyQt5 import QtCore -from PyQt5.QtCore import Qt - from capa.ida.plugin.model import CapaExplorerDataModel +from capa.ida.plugin.qt_compat import Qt, QtCore class CapaExplorerRangeProxyModel(QtCore.QSortFilterProxyModel): diff --git a/capa/ida/plugin/qt_compat.py b/capa/ida/plugin/qt_compat.py new file mode 100644 index 0000000000..7b3858a719 --- /dev/null +++ b/capa/ida/plugin/qt_compat.py @@ -0,0 +1,79 @@ +# 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. + +""" +Qt compatibility layer for capa IDA Pro plugin. + +Handles PyQt5 (IDA < 9.2) vs PySide6 (IDA >= 9.2) differences. +This module provides a unified import interface for Qt modules and handles +API changes between Qt5 and Qt6. +""" + +try: + # IDA 9.2+ uses PySide6 + from PySide6 import QtGui, QtCore, QtWidgets + from PySide6.QtGui import QAction + + QT_LIBRARY = "PySide6" + Signal = QtCore.Signal +except ImportError: + # Older IDA versions use PyQt5 + try: + from PyQt5 import QtGui, QtCore, QtWidgets + from PyQt5.QtWidgets import QAction + + QT_LIBRARY = "PyQt5" + Signal = QtCore.pyqtSignal + except ImportError: + raise ImportError("Neither PySide6 nor PyQt5 is available. Cannot initialize capa IDA plugin.") + +Qt = QtCore.Qt + + +def qt_get_item_flag_tristate(): + """ + Get the tristate item flag compatible with Qt5 and Qt6. + + Qt5 (PyQt5): Uses Qt.ItemIsTristate + Qt6 (PySide6): Qt.ItemIsTristate was removed, uses Qt.ItemIsAutoTristate + + ItemIsAutoTristate automatically manages tristate based on child checkboxes, + matching the original ItemIsTristate behavior where parent checkboxes reflect + the check state of their children. + + Returns: + int: The appropriate flag value for the Qt version + + Raises: + AttributeError: If the tristate flag cannot be found in the Qt library + """ + if QT_LIBRARY == "PySide6": + # Qt6: ItemIsTristate was removed, replaced with ItemIsAutoTristate + # Try different possible locations (API varies slightly across PySide6 versions) + if hasattr(Qt, "ItemIsAutoTristate"): + return Qt.ItemIsAutoTristate + elif hasattr(Qt, "ItemFlag") and hasattr(Qt.ItemFlag, "ItemIsAutoTristate"): + return Qt.ItemFlag.ItemIsAutoTristate + else: + raise AttributeError( + "Cannot find ItemIsAutoTristate in PySide6. " + + "Your PySide6 version may be incompatible with capa. " + + f"Available Qt attributes: {[attr for attr in dir(Qt) if 'Item' in attr]}" + ) + else: + # Qt5: Use the original ItemIsTristate flag + return Qt.ItemIsTristate + + +__all__ = ["qt_get_item_flag_tristate", "Signal", "QAction", "QtGui", "QtCore", "QtWidgets"] diff --git a/capa/ida/plugin/view.py b/capa/ida/plugin/view.py index ed188a841c..a442f4d1e9 100644 --- a/capa/ida/plugin/view.py +++ b/capa/ida/plugin/view.py @@ -18,7 +18,6 @@ import idc import idaapi -from PyQt5 import QtGui, QtCore, QtWidgets import capa.rules import capa.engine @@ -28,6 +27,7 @@ from capa.ida.plugin.item import CapaExplorerFunctionItem from capa.features.address import AbsoluteVirtualAddress, _NoAddress from capa.ida.plugin.model import CapaExplorerDataModel +from capa.ida.plugin.qt_compat import QtGui, QtCore, Signal, QAction, QtWidgets MAX_SECTION_SIZE = 750 @@ -147,7 +147,7 @@ def calc_item_depth(o): def build_action(o, display, data, slot): """ """ - action = QtWidgets.QAction(display, o) + action = QAction(display, o) action.setData(data) action.triggered.connect(lambda checked: slot(action)) @@ -312,7 +312,7 @@ def set_selection(self, start, end, max): class CapaExplorerRulegenEditor(QtWidgets.QTreeWidget): - updated = QtCore.pyqtSignal() + updated = Signal() def __init__(self, preview, parent=None): """ """ diff --git a/doc/release.md b/doc/release.md index 6d85290079..3b8242a5db 100644 --- a/doc/release.md +++ b/doc/release.md @@ -7,6 +7,7 @@ - [ ] Review changes - capa https://github.com/mandiant/capa/compare/\...master - capa-rules https://github.com/mandiant/capa-rules/compare/\\...master +- [ ] Run `$ bump-my-version bump {patch/minor/major} [--allow-dirty]` to update [capa/version.py](https://github.com/mandiant/capa/blob/master/capa/version.py) and other version files - [ ] Update [CHANGELOG.md](https://github.com/mandiant/capa/blob/master/CHANGELOG.md) - Do not forget to add a nice introduction thanking contributors - Remember that we need a major release if we introduce breaking changes @@ -36,7 +37,6 @@ - [capa ...master](https://github.com/mandiant/capa/compare/...master) - [capa-rules ...master](https://github.com/mandiant/capa-rules/compare/...master) ``` -- [ ] Update [capa/version.py](https://github.com/mandiant/capa/blob/master/capa/version.py) - [ ] Create a PR with the updated [CHANGELOG.md](https://github.com/mandiant/capa/blob/master/CHANGELOG.md) and [capa/version.py](https://github.com/mandiant/capa/blob/master/capa/version.py). Copy this checklist in the PR description. - [ ] Update the [homepage](https://github.com/mandiant/capa/blob/master/web/public/index.html) (i.e. What's New section) - [ ] After PR review, merge the PR and [create the release in GH](https://github.com/mandiant/capa/releases/new) using text from the [CHANGELOG.md](https://github.com/mandiant/capa/blob/master/CHANGELOG.md). diff --git a/pyproject.toml b/pyproject.toml index c2fcbac4a4..31f1a03bc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ # comments and context. "pyyaml>=6", "colorama>=0.4", - "ida-settings>=2.1.0,<3", # v3 has breaking changes + "ida-settings>=3.1.0", "ruamel.yaml>=0.18", "pefile>=2023.2.7", "pyelftools>=0.31", @@ -104,7 +104,7 @@ dependencies = [ "networkx>=3", - "dnfile>=0.15.0", + "dnfile>=0.17.0", ] dynamic = ["version"] @@ -142,6 +142,7 @@ dev = [ "mypy==1.17.1", "mypy-protobuf==3.6.0", "PyGithub==2.6.0", + "bump-my-version==1.2.4", # type stubs for mypy "types-backports==0.1.3", "types-colorama==0.4.15.11", @@ -161,11 +162,13 @@ build = [ "build==1.2.2" ] scripts = [ + # can (optionally) be more lenient on dependencies here + # see comment on dependencies for more context "jschema_to_python==1.2.3", - "psutil==7.0.0", + "psutil==7.1.2", "stix2==3.0.1", "sarif_om==1.0.4", - "requests==2.32.3", + "requests>=2.32.4", ] [tool.deptry] @@ -197,7 +200,8 @@ known_first_party = [ "idc", "java", "netnode", - "PyQt5" + "PyQt5", + "PySide6" ] [tool.deptry.per_rule_ignores] @@ -205,6 +209,7 @@ known_first_party = [ DEP002 = [ "black", "build", + "bump-my-version", "deptry", "flake8", "flake8-bugbear", diff --git a/requirements.txt b/requirements.txt index 32a476e8e1..5f372a19b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,11 +10,11 @@ annotated-types==0.7.0 colorama==0.4.6 cxxfilt==0.3.0 dncil==1.0.2 -dnfile==0.16.4 +dnfile==0.17.0 funcy==2.0 humanize==4.13.0 ida-netnode==3.0 -ida-settings==2.1.0 +ida-settings==3.2.2 intervaltree==3.1.0 markdown-it-py==4.0.0 mdurl==0.1.2 @@ -36,12 +36,13 @@ pyelftools==0.32 pygments==2.19.1 python-flirt==0.9.2 pyyaml==6.0.2 -rich==14.0.0 +rich==14.2.0 ruamel-yaml==0.18.6 -ruamel-yaml-clib==0.2.8 +ruamel-yaml-clib==0.2.14 setuptools==80.9.0 six==1.17.0 sortedcontainers==2.4.0 viv-utils==0.8.0 vivisect==1.2.1 msgspec==0.19.0 +bump-my-version==1.2.4 diff --git a/rules b/rules index fa246a4a9b..9e4cc28265 160000 --- a/rules +++ b/rules @@ -1 +1 @@ -Subproject commit fa246a4a9b869f071efa9c1d3a00ac447efc5efe +Subproject commit 9e4cc2826585d1c939aee25df5067330937d7655 diff --git a/tests/data b/tests/data index af439c7555..5ea5d9f572 160000 --- a/tests/data +++ b/tests/data @@ -1 +1 @@ -Subproject commit af439c7555779346250c2c6a0c1723042bf1fb9c +Subproject commit 5ea5d9f572902c9c74f0e662ff2c9a5d600bb4ed diff --git a/web/explorer/package-lock.json b/web/explorer/package-lock.json index 0f88dd8b02..f756f8d5cf 100644 --- a/web/explorer/package-lock.json +++ b/web/explorer/package-lock.json @@ -27,7 +27,7 @@ "eslint-plugin-vue": "^9.23.0", "jsdom": "^24.1.0", "prettier": "^3.2.5", - "vite": "^6.3.4", + "vite": "^6.4.1", "vite-plugin-singlefile": "^2.2.0", "vitest": "^3.0.9" } @@ -3801,9 +3801,9 @@ "dev": true }, "node_modules/vite": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", - "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/explorer/package.json b/web/explorer/package.json index b72fc58123..82920b7f96 100644 --- a/web/explorer/package.json +++ b/web/explorer/package.json @@ -33,7 +33,7 @@ "eslint-plugin-vue": "^9.23.0", "jsdom": "^24.1.0", "prettier": "^3.2.5", - "vite": "^6.3.4", + "vite": "^6.4.1", "vite-plugin-singlefile": "^2.2.0", "vitest": "^3.0.9" }