mirror of
https://github.com/mandiant/capa.git
synced 2026-03-16 06:48:58 -07:00
Compare commits
11 Commits
update-ida
...
fix-2745
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34d37c9129 | ||
|
|
92b6916030 | ||
|
|
14996956ea | ||
|
|
2ce7c6a388 | ||
|
|
5b48ae009a | ||
|
|
abdd18d897 | ||
|
|
9f94375391 | ||
|
|
8f9678af4f | ||
|
|
38dc92d2fa | ||
|
|
92e8e49532 | ||
|
|
6a727fa8c0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -122,7 +122,6 @@ scripts/perf/*.zip
|
||||
*/.DS_Store
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
uv.lock
|
||||
/cache/
|
||||
.github/binja/binaryninja
|
||||
.github/binja/download_headless.py
|
||||
|
||||
@@ -34,14 +34,13 @@
|
||||
### Bug Fixes
|
||||
|
||||
- binja: fix a crash during feature extraction when the MLIL is unavailable @xusheng6 #2714
|
||||
- embedded pe: use FileOffset rather than AbsoluteVirtualAddress for IDA, Ghidra, and Binary Ninja @williballenthin #2745
|
||||
|
||||
### 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
|
||||
- ida plugin: update ida-settings API @mr-tz #2736
|
||||
|
||||
### Development
|
||||
|
||||
|
||||
@@ -315,6 +315,3 @@ If you use Ghidra, then you can use the [capa + Ghidra integration](/capa/ghidra
|
||||
|
||||
## capa testfiles
|
||||
The [capa-testfiles repository](https://github.com/mandiant/capa-testfiles) contains the data we use to test capa's code and rules
|
||||
|
||||
## mailing list
|
||||
Subscribe to the FLARE mailing list for community announcements! Email "subscribe" to [flare-external@google.com](mailto:flare-external@google.com?subject=subscribe).
|
||||
|
||||
@@ -32,7 +32,7 @@ from capa.features.common import (
|
||||
Characteristic,
|
||||
)
|
||||
from capa.features.address import NO_ADDRESS, Address, FileOffsetAddress, AbsoluteVirtualAddress
|
||||
from capa.features.extractors.binja.helpers import read_c_string, unmangle_c_name
|
||||
from capa.features.extractors.binja.helpers import read_c_string, unmangle_c_name, va_to_file_offset
|
||||
|
||||
|
||||
def check_segment_for_pe(bv: BinaryView, seg: Segment) -> Iterator[tuple[Feature, Address]]:
|
||||
@@ -46,7 +46,8 @@ def check_segment_for_pe(bv: BinaryView, seg: Segment) -> Iterator[tuple[Feature
|
||||
buf = bv.read(seg.start, seg.length)
|
||||
|
||||
for offset, _ in capa.features.extractors.helpers.carve_pe(buf, start):
|
||||
yield Characteristic("embedded pe"), FileOffsetAddress(seg.start + offset)
|
||||
file_off = va_to_file_offset(bv, seg.start + offset)
|
||||
yield Characteristic("embedded pe"), FileOffsetAddress(file_off)
|
||||
|
||||
|
||||
def extract_file_embedded_pe(bv: BinaryView) -> Iterator[tuple[Feature, Address]]:
|
||||
@@ -122,7 +123,8 @@ def extract_file_section_names(bv: BinaryView) -> Iterator[tuple[Feature, Addres
|
||||
def extract_file_strings(bv: BinaryView) -> Iterator[tuple[Feature, Address]]:
|
||||
"""extract ASCII and UTF-16 LE strings"""
|
||||
for s in bv.strings:
|
||||
yield String(s.value), FileOffsetAddress(s.start)
|
||||
file_off = va_to_file_offset(bv, s.start)
|
||||
yield String(s.value), FileOffsetAddress(file_off)
|
||||
|
||||
|
||||
def extract_file_function_names(bv: BinaryView) -> Iterator[tuple[Feature, Address]]:
|
||||
|
||||
@@ -84,3 +84,29 @@ def get_llil_instr_at_addr(bv: BinaryView, addr: int) -> Optional[LowLevelILInst
|
||||
if arch.get_instruction_low_level_il(buffer, addr, llil) == 0:
|
||||
return None
|
||||
return llil[0]
|
||||
|
||||
|
||||
def va_to_file_offset(bv: BinaryView, va: int) -> int:
|
||||
"""Map a BinaryView virtual address to a file offset using segment/section data offsets.
|
||||
|
||||
Assumes a modern Binary Ninja API where Segment and Section objects expose
|
||||
a `data_offset` attribute which is the file offset of the start of the
|
||||
segment/section. The file offset is computed as:
|
||||
|
||||
file_offset = segment.data_offset + (va - segment.start)
|
||||
|
||||
If no containing segment/section is found, fall back to returning the
|
||||
given virtual address as an integer.
|
||||
"""
|
||||
# prefer segments (they map ranges of the file view)
|
||||
for seg in bv.segments:
|
||||
if seg.start <= va < seg.start + seg.length:
|
||||
return int(seg.data_offset + (va - seg.start))
|
||||
|
||||
# otherwise check sections
|
||||
for _, sec in bv.sections.items():
|
||||
if sec.start <= va < sec.start + sec.length:
|
||||
return int(sec.data_offset + (va - sec.start))
|
||||
|
||||
# fallback
|
||||
return int(va)
|
||||
|
||||
@@ -85,10 +85,11 @@ def extract_file_embedded_pe() -> Iterator[tuple[Feature, Address]]:
|
||||
continue
|
||||
|
||||
for off, _ in find_embedded_pe(capa.features.extractors.ghidra.helpers.get_block_bytes(block), mz_xor):
|
||||
# add offset back to block start
|
||||
ea: int = block.getStart().add(off).getOffset()
|
||||
# add offset back to block start (Address)
|
||||
addr = block.getStart().add(off)
|
||||
off_file = capa.features.extractors.ghidra.helpers.addr_to_file_offset(addr)
|
||||
|
||||
yield Characteristic("embedded pe"), FileOffsetAddress(ea)
|
||||
yield Characteristic("embedded pe"), FileOffsetAddress(int(off_file))
|
||||
|
||||
|
||||
def extract_file_export_names() -> Iterator[tuple[Feature, Address]]:
|
||||
@@ -140,12 +141,14 @@ def extract_file_strings() -> Iterator[tuple[Feature, Address]]:
|
||||
p_bytes = capa.features.extractors.ghidra.helpers.get_block_bytes(block)
|
||||
|
||||
for s in capa.features.extractors.strings.extract_ascii_strings(p_bytes):
|
||||
offset = block.getStart().getOffset() + s.offset
|
||||
yield String(s.s), FileOffsetAddress(offset)
|
||||
addr = block.getStart().add(s.offset)
|
||||
offset = capa.features.extractors.ghidra.helpers.addr_to_file_offset(addr)
|
||||
yield String(s.s), FileOffsetAddress(int(offset))
|
||||
|
||||
for s in capa.features.extractors.strings.extract_unicode_strings(p_bytes):
|
||||
offset = block.getStart().getOffset() + s.offset
|
||||
yield String(s.s), FileOffsetAddress(offset)
|
||||
addr = block.getStart().add(s.offset)
|
||||
offset = capa.features.extractors.ghidra.helpers.addr_to_file_offset(addr)
|
||||
yield String(s.s), FileOffsetAddress(int(offset))
|
||||
|
||||
|
||||
def extract_file_function_names() -> Iterator[tuple[Feature, Address]]:
|
||||
|
||||
@@ -306,3 +306,31 @@ def find_data_references_from_insn(insn, max_depth: int = 10):
|
||||
break
|
||||
|
||||
yield to_addr
|
||||
|
||||
|
||||
def addr_to_file_offset(addr: ghidra.program.model.address.Address) -> int:
|
||||
"""Map a Ghidra Address to a file offset using section information.
|
||||
|
||||
Assumes a modern Ghidra version where MemoryBlock provides
|
||||
`getStartingOffset()` and `getStart()/getEnd()` are available.
|
||||
|
||||
Algorithm:
|
||||
- iterate memory blocks, find the block containing `addr`
|
||||
- compute section-relative offset = addr - block.start
|
||||
- compute file offset = block.getStartingOffset() + section-relative offset
|
||||
- if no block matches, fall back to subtracting program image base
|
||||
"""
|
||||
prog = currentProgram() # type: ignore[name-defined] # noqa: F821
|
||||
aoff = addr.getOffset()
|
||||
|
||||
for block in prog.getMemory().getBlocks(): # type: ignore[name-defined] # noqa: F821
|
||||
bstart = block.getStart().getOffset()
|
||||
bend = block.getEnd().getOffset()
|
||||
if bstart <= aoff <= bend:
|
||||
sec_rel = aoff - bstart
|
||||
file_base = block.getStartingOffset()
|
||||
return int(file_base + sec_rel)
|
||||
|
||||
# if no block matched, fall back to image-base subtraction
|
||||
base = prog.getImageBase().getOffset()
|
||||
return int(aoff - base)
|
||||
|
||||
@@ -20,6 +20,7 @@ import idc
|
||||
import idaapi
|
||||
import idautils
|
||||
import ida_entry
|
||||
import ida_loader
|
||||
|
||||
import capa.ida.helpers
|
||||
import capa.features.extractors.common
|
||||
@@ -87,7 +88,8 @@ def extract_file_embedded_pe() -> Iterator[tuple[Feature, Address]]:
|
||||
"""
|
||||
for seg in capa.features.extractors.ida.helpers.get_segments(skip_header_segments=True):
|
||||
for ea, _ in check_segment_for_pe(seg):
|
||||
yield Characteristic("embedded pe"), FileOffsetAddress(ea)
|
||||
off = ida_loader.get_fileregion_offset(ea)
|
||||
yield Characteristic("embedded pe"), FileOffsetAddress(off)
|
||||
|
||||
|
||||
def extract_file_export_names() -> Iterator[tuple[Feature, Address]]:
|
||||
@@ -161,10 +163,12 @@ def extract_file_strings() -> Iterator[tuple[Feature, Address]]:
|
||||
|
||||
# differing to common string extractor factor in segment offset here
|
||||
for s in capa.features.extractors.strings.extract_ascii_strings(seg_buff):
|
||||
yield String(s.s), FileOffsetAddress(seg.start_ea + s.offset)
|
||||
off = ida_loader.get_fileregion_offset(seg.start_ea + s.offset)
|
||||
yield String(s.s), FileOffsetAddress(off)
|
||||
|
||||
for s in capa.features.extractors.strings.extract_unicode_strings(seg_buff):
|
||||
yield String(s.s), FileOffsetAddress(seg.start_ea + s.offset)
|
||||
off = ida_loader.get_fileregion_offset(seg.start_ea + s.offset)
|
||||
yield String(s.s), FileOffsetAddress(off)
|
||||
|
||||
|
||||
def extract_file_function_names() -> Iterator[tuple[Feature, Address]]:
|
||||
|
||||
@@ -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 @@ from capa.features.extractors.base_extractor import FunctionHandle
|
||||
class CapaExplorerProgressIndicator(QtCore.QObject):
|
||||
"""implement progress signal, used during feature extraction"""
|
||||
|
||||
progress = Signal(str)
|
||||
progress = QtCore.pyqtSignal(str)
|
||||
|
||||
def update(self, text):
|
||||
"""emit progress update
|
||||
|
||||
@@ -23,6 +23,7 @@ from pathlib import Path
|
||||
import idaapi
|
||||
import ida_kernwin
|
||||
import ida_settings
|
||||
from PyQt5 import QtGui, QtCore, QtWidgets
|
||||
|
||||
import capa.main
|
||||
import capa.rules
|
||||
@@ -50,10 +51,10 @@ from capa.ida.plugin.hooks import CapaExplorerIdaHooks
|
||||
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__)
|
||||
settings = ida_settings.IDASettings("capa")
|
||||
|
||||
CAPA_SETTINGS_RULE_PATH = "rule_path"
|
||||
CAPA_SETTINGS_RULEGEN_AUTHOR = "rulegen_author"
|
||||
@@ -78,13 +79,6 @@ AnalyzeOptionsText = {
|
||||
}
|
||||
|
||||
|
||||
def get_setting(key: str, default=None):
|
||||
try:
|
||||
return ida_settings.get_current_plugin_setting(key)
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
|
||||
def write_file(path: Path, data):
|
||||
""" """
|
||||
path.write_bytes(data)
|
||||
@@ -113,7 +107,7 @@ class QLineEditClicked(QtWidgets.QLineEdit):
|
||||
old = self.text()
|
||||
new = str(
|
||||
QtWidgets.QFileDialog.getExistingDirectory(
|
||||
self.parent(), "Please select a capa rules directory", get_setting(CAPA_SETTINGS_RULE_PATH, "")
|
||||
self.parent(), "Please select a capa rules directory", settings.user.get(CAPA_SETTINGS_RULE_PATH, "")
|
||||
)
|
||||
)
|
||||
if new:
|
||||
@@ -131,8 +125,8 @@ class CapaSettingsInputDialog(QtWidgets.QDialog):
|
||||
self.setMinimumWidth(500)
|
||||
self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
|
||||
|
||||
self.edit_rule_path = QLineEditClicked(get_setting(CAPA_SETTINGS_RULE_PATH, ""))
|
||||
self.edit_rule_author = QtWidgets.QLineEdit(get_setting(CAPA_SETTINGS_RULEGEN_AUTHOR, ""))
|
||||
self.edit_rule_path = QLineEditClicked(settings.user.get(CAPA_SETTINGS_RULE_PATH, ""))
|
||||
self.edit_rule_author = QtWidgets.QLineEdit(settings.user.get(CAPA_SETTINGS_RULEGEN_AUTHOR, ""))
|
||||
self.edit_rule_scope = QtWidgets.QComboBox()
|
||||
self.edit_rules_link = QtWidgets.QLabel()
|
||||
self.edit_analyze = QtWidgets.QComboBox()
|
||||
@@ -147,11 +141,11 @@ class CapaSettingsInputDialog(QtWidgets.QDialog):
|
||||
|
||||
scopes = ("file", "function", "basic block", "instruction")
|
||||
self.edit_rule_scope.addItems(scopes)
|
||||
self.edit_rule_scope.setCurrentIndex(scopes.index(get_setting(CAPA_SETTINGS_RULEGEN_SCOPE, "function")))
|
||||
self.edit_rule_scope.setCurrentIndex(scopes.index(settings.user.get(CAPA_SETTINGS_RULEGEN_SCOPE, "function")))
|
||||
|
||||
self.edit_analyze.addItems(AnalyzeOptionsText.values())
|
||||
# set the default analysis option here
|
||||
self.edit_analyze.setCurrentIndex(get_setting(CAPA_SETTINGS_ANALYZE, Options.NO_ANALYSIS))
|
||||
self.edit_analyze.setCurrentIndex(settings.user.get(CAPA_SETTINGS_ANALYZE, Options.NO_ANALYSIS))
|
||||
|
||||
buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, self)
|
||||
|
||||
@@ -241,7 +235,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
|
||||
self.Show()
|
||||
|
||||
analyze = get_setting(CAPA_SETTINGS_ANALYZE)
|
||||
analyze = settings.user.get(CAPA_SETTINGS_ANALYZE)
|
||||
if analyze != Options.NO_ANALYSIS or (option & Options.ANALYZE_AUTO) == Options.ANALYZE_AUTO:
|
||||
self.analyze_program(analyze=analyze)
|
||||
|
||||
@@ -587,7 +581,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
|
||||
def ensure_capa_settings_rule_path(self):
|
||||
try:
|
||||
path: str = get_setting(CAPA_SETTINGS_RULE_PATH, "")
|
||||
path: str = settings.user.get(CAPA_SETTINGS_RULE_PATH, "")
|
||||
|
||||
# resolve rules directory - check self and settings first, then ask user
|
||||
# pathlib.Path considers "" equivalent to "." so we first check if rule path is an empty string
|
||||
@@ -617,7 +611,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
logger.error("rule path %s does not exist or cannot be accessed", path)
|
||||
return False
|
||||
|
||||
ida_settings.set_current_plugin_setting(CAPA_SETTINGS_RULE_PATH, path)
|
||||
settings.user[CAPA_SETTINGS_RULE_PATH] = path
|
||||
except UserCancelledError:
|
||||
capa.ida.helpers.inform_user_ida_ui("Analysis requires capa rules")
|
||||
logger.warning(
|
||||
@@ -641,7 +635,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
if not self.ensure_capa_settings_rule_path():
|
||||
return False
|
||||
|
||||
rule_path: Path = Path(get_setting(CAPA_SETTINGS_RULE_PATH, ""))
|
||||
rule_path: Path = Path(settings.user.get(CAPA_SETTINGS_RULE_PATH, ""))
|
||||
try:
|
||||
|
||||
def on_load_rule(_, i, total):
|
||||
@@ -655,10 +649,10 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
return None
|
||||
except Exception as e:
|
||||
capa.ida.helpers.inform_user_ida_ui(
|
||||
f"Failed to load capa rules from {get_setting(CAPA_SETTINGS_RULE_PATH)}"
|
||||
f"Failed to load capa rules from {settings.user[CAPA_SETTINGS_RULE_PATH]}"
|
||||
)
|
||||
|
||||
logger.error("Failed to load capa rules from %s (error: %s).", get_setting(CAPA_SETTINGS_RULE_PATH), e)
|
||||
logger.error("Failed to load capa rules from %s (error: %s).", settings.user[CAPA_SETTINGS_RULE_PATH], e)
|
||||
logger.error(
|
||||
"Make sure your file directory contains properly " # noqa: G003 [logging statement uses +]
|
||||
+ "formatted capa rules. You can download and extract the official rules from %s. "
|
||||
@@ -667,7 +661,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
CAPA_RULESET_DOC_URL,
|
||||
)
|
||||
|
||||
ida_settings.set_current_plugin_setting(CAPA_SETTINGS_RULE_PATH, "")
|
||||
settings.user[CAPA_SETTINGS_RULE_PATH] = ""
|
||||
return None
|
||||
|
||||
def load_capa_results(self, new_analysis, from_cache):
|
||||
@@ -707,7 +701,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
update_wait_box("verifying cached results")
|
||||
|
||||
count_source_rules = self.program_analysis_ruleset_cache.source_rule_count
|
||||
user_settings = get_setting(CAPA_SETTINGS_RULE_PATH)
|
||||
user_settings = settings.user[CAPA_SETTINGS_RULE_PATH]
|
||||
view_status_rules: str = f"{user_settings} ({count_source_rules} rules)"
|
||||
|
||||
# warn user about potentially outdated rules, depending on the use-case this may be expected
|
||||
@@ -781,7 +775,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
update_wait_box("extracting features")
|
||||
|
||||
try:
|
||||
meta = capa.ida.helpers.collect_metadata([Path(get_setting(CAPA_SETTINGS_RULE_PATH))])
|
||||
meta = capa.ida.helpers.collect_metadata([Path(settings.user[CAPA_SETTINGS_RULE_PATH])])
|
||||
capabilities = capa.capabilities.common.find_capabilities(
|
||||
ruleset, self.feature_extractor, disable_progress=True
|
||||
)
|
||||
@@ -861,7 +855,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
except Exception as e:
|
||||
logger.exception("Failed to save results to database (error: %s)", e)
|
||||
return False
|
||||
user_settings = get_setting(CAPA_SETTINGS_RULE_PATH)
|
||||
user_settings = settings.user[CAPA_SETTINGS_RULE_PATH]
|
||||
count_source_rules = self.program_analysis_ruleset_cache.source_rule_count
|
||||
new_view_status = f"capa rules: {user_settings} ({count_source_rules} rules)"
|
||||
# regardless of new analysis, render results - e.g. we may only want to render results after checking
|
||||
@@ -1082,13 +1076,13 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
# load preview and feature tree
|
||||
self.view_rulegen_preview.load_preview_meta(
|
||||
self.rulegen_current_function.address if self.rulegen_current_function else None,
|
||||
get_setting(CAPA_SETTINGS_RULEGEN_AUTHOR, "<insert_author>"),
|
||||
get_setting(CAPA_SETTINGS_RULEGEN_SCOPE, "function"),
|
||||
settings.user.get(CAPA_SETTINGS_RULEGEN_AUTHOR, "<insert_author>"),
|
||||
settings.user.get(CAPA_SETTINGS_RULEGEN_SCOPE, "function"),
|
||||
)
|
||||
|
||||
self.view_rulegen_features.load_features(all_file_features, all_function_features)
|
||||
|
||||
self.set_view_status_label(f"capa rules: {get_setting(CAPA_SETTINGS_RULE_PATH)}")
|
||||
self.set_view_status_label(f"capa rules: {settings.user[CAPA_SETTINGS_RULE_PATH]}")
|
||||
except Exception as e:
|
||||
logger.exception("Failed to render views (error: %s)", e)
|
||||
return False
|
||||
@@ -1309,11 +1303,12 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
""" """
|
||||
dialog = CapaSettingsInputDialog("capa explorer settings", parent=self.parent)
|
||||
if dialog.exec_():
|
||||
rule_path, rule_author, rule_scope, analyze = dialog.get_values()
|
||||
ida_settings.set_current_plugin_setting(CAPA_SETTINGS_RULE_PATH, rule_path)
|
||||
ida_settings.set_current_plugin_setting(CAPA_SETTINGS_RULEGEN_AUTHOR, rule_author)
|
||||
ida_settings.set_current_plugin_setting(CAPA_SETTINGS_RULEGEN_SCOPE, rule_scope)
|
||||
ida_settings.set_current_plugin_setting(CAPA_SETTINGS_ANALYZE, analyze)
|
||||
(
|
||||
settings.user[CAPA_SETTINGS_RULE_PATH],
|
||||
settings.user[CAPA_SETTINGS_RULEGEN_AUTHOR],
|
||||
settings.user[CAPA_SETTINGS_RULEGEN_SCOPE],
|
||||
settings.user[CAPA_SETTINGS_ANALYZE],
|
||||
) = dialog.get_values()
|
||||
|
||||
def save_program_analysis(self):
|
||||
""" """
|
||||
@@ -1363,7 +1358,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
|
||||
@param state: checked state
|
||||
"""
|
||||
if state:
|
||||
if state == QtCore.Qt.Checked:
|
||||
self.limit_results_to_function(idaapi.get_func(idaapi.get_screen_ea()))
|
||||
else:
|
||||
self.range_model_proxy.reset_address_range_filter()
|
||||
@@ -1372,7 +1367,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
|
||||
def slot_checkbox_limit_features_by_ea(self, state):
|
||||
""" """
|
||||
if state:
|
||||
if state == QtCore.Qt.Checked:
|
||||
self.view_rulegen_features.filter_items_by_ea(idaapi.get_screen_ea())
|
||||
else:
|
||||
self.view_rulegen_features.show_all_items()
|
||||
@@ -1413,7 +1408,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
"""create Qt dialog to ask user for a directory"""
|
||||
return str(
|
||||
QtWidgets.QFileDialog.getExistingDirectory(
|
||||
self.parent, "Please select a capa rules directory", get_setting(CAPA_SETTINGS_RULE_PATH, "")
|
||||
self.parent, "Please select a capa rules directory", settings.user.get(CAPA_SETTINGS_RULE_PATH, "")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1422,7 +1417,7 @@ class CapaExplorerForm(idaapi.PluginForm):
|
||||
return QtWidgets.QFileDialog.getSaveFileName(
|
||||
None,
|
||||
"Please select a location to save capa rule file",
|
||||
get_setting(CAPA_SETTINGS_RULE_PATH, ""),
|
||||
settings.user.get(CAPA_SETTINGS_RULE_PATH, ""),
|
||||
"*.yml",
|
||||
)[0]
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ from typing import Iterator, Optional
|
||||
|
||||
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 @@ class CapaExplorerDataItem:
|
||||
self.flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
||||
|
||||
if self._can_check:
|
||||
self.flags = self.flags | QtCore.Qt.ItemIsUserCheckable | qt_get_item_flag_tristate()
|
||||
self.flags = self.flags | QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsTristate
|
||||
|
||||
if self.pred:
|
||||
self.pred.appendChild(self)
|
||||
|
||||
@@ -18,6 +18,7 @@ from collections import deque
|
||||
|
||||
import idc
|
||||
import idaapi
|
||||
from PyQt5 import QtGui, QtCore
|
||||
|
||||
import capa.rules
|
||||
import capa.ida.helpers
|
||||
@@ -41,7 +42,6 @@ from capa.ida.plugin.item import (
|
||||
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 @@ class CapaExplorerDataModel(QtCore.QAbstractItemModel):
|
||||
visited.add(child_index)
|
||||
|
||||
for idx in range(self.rowCount(child_index)):
|
||||
stack.append(self.index(idx, 0, child_index))
|
||||
stack.append(child_index.child(idx, 0))
|
||||
|
||||
def reset_ida_highlighting(self, item, checked):
|
||||
"""reset IDA highlight for item
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
# 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):
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# 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"]
|
||||
@@ -18,6 +18,7 @@ from collections import Counter
|
||||
|
||||
import idc
|
||||
import idaapi
|
||||
from PyQt5 import QtGui, QtCore, QtWidgets
|
||||
|
||||
import capa.rules
|
||||
import capa.engine
|
||||
@@ -27,7 +28,6 @@ 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
|
||||
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 = QAction(display, o)
|
||||
action = QtWidgets.QAction(display, o)
|
||||
|
||||
action.setData(data)
|
||||
action.triggered.connect(lambda checked: slot(action))
|
||||
@@ -312,7 +312,7 @@ class CapaExplorerRulegenPreview(QtWidgets.QTextEdit):
|
||||
|
||||
|
||||
class CapaExplorerRulegenEditor(QtWidgets.QTreeWidget):
|
||||
updated = Signal()
|
||||
updated = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(self, preview, parent=None):
|
||||
""" """
|
||||
|
||||
@@ -74,7 +74,7 @@ dependencies = [
|
||||
# comments and context.
|
||||
"pyyaml>=6",
|
||||
"colorama>=0.4",
|
||||
"ida-settings>=3.1.0",
|
||||
"ida-settings>=2.1.0,<3", # v3 has breaking changes
|
||||
"ruamel.yaml>=0.18",
|
||||
"pefile>=2023.2.7",
|
||||
"pyelftools>=0.31",
|
||||
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
"networkx>=3",
|
||||
|
||||
"dnfile>=0.17.0",
|
||||
"dnfile>=0.15.0",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
@@ -149,7 +149,7 @@ dev = [
|
||||
"types-PyYAML==6.0.8",
|
||||
"types-psutil==7.0.0.20250218",
|
||||
"types_requests==2.32.0.20240712",
|
||||
"types-protobuf==6.32.1.20250918",
|
||||
"types-protobuf==6.30.2.20250516",
|
||||
"deptry==0.23.0"
|
||||
]
|
||||
build = [
|
||||
@@ -162,13 +162,11 @@ 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.1.2",
|
||||
"psutil==7.0.0",
|
||||
"stix2==3.0.1",
|
||||
"sarif_om==1.0.4",
|
||||
"requests>=2.32.4",
|
||||
"requests==2.32.3",
|
||||
]
|
||||
|
||||
[tool.deptry]
|
||||
@@ -200,8 +198,7 @@ known_first_party = [
|
||||
"idc",
|
||||
"java",
|
||||
"netnode",
|
||||
"PyQt5",
|
||||
"PySide6"
|
||||
"PyQt5"
|
||||
]
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
|
||||
@@ -10,18 +10,18 @@ annotated-types==0.7.0
|
||||
colorama==0.4.6
|
||||
cxxfilt==0.3.0
|
||||
dncil==1.0.2
|
||||
dnfile==0.17.0
|
||||
dnfile==0.16.4
|
||||
funcy==2.0
|
||||
humanize==4.13.0
|
||||
ida-netnode==3.0
|
||||
ida-settings==3.2.2
|
||||
ida-settings==2.1.0
|
||||
intervaltree==3.1.0
|
||||
markdown-it-py==4.0.0
|
||||
mdurl==0.1.2
|
||||
msgpack==1.0.8
|
||||
networkx==3.4.2
|
||||
pefile==2024.8.26
|
||||
pip==25.3
|
||||
pip==25.2
|
||||
protobuf==6.31.1
|
||||
pyasn1==0.5.1
|
||||
pyasn1-modules==0.3.0
|
||||
@@ -38,7 +38,7 @@ python-flirt==0.9.2
|
||||
pyyaml==6.0.2
|
||||
rich==14.2.0
|
||||
ruamel-yaml==0.18.6
|
||||
ruamel-yaml-clib==0.2.14
|
||||
ruamel-yaml-clib==0.2.8
|
||||
setuptools==80.9.0
|
||||
six==1.17.0
|
||||
sortedcontainers==2.4.0
|
||||
|
||||
Reference in New Issue
Block a user