diff --git a/.gitignore b/.gitignore index cef85dbae6..11fee7e039 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,10 @@ uv.lock .github/binja/BinaryNinja-headless.zip justfile data/ +.gradle.gradle/ +build/ +.DS_Store +.gradle/ +**/.gradle/ +.DS_Store +**/.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index cd49da66c9..5b0cb784ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### New Features +- ghidra: experimental capa explorer extension (MVP) @vaishakh787 - ghidra: support PyGhidra @mike-hunhoff #2788 - vmray: extract number features from whitelisted void_ptr parameters (hKey, hKeyRoot) @adeboyedn #2835 diff --git a/capa/ghidra/plugin/extension/README.md b/capa/ghidra/plugin/extension/README.md new file mode 100644 index 0000000000..08fe9847ff --- /dev/null +++ b/capa/ghidra/plugin/extension/README.md @@ -0,0 +1 @@ +# CapaExplorer diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/AbstractTreeTableModel.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/AbstractTreeTableModel.class new file mode 100644 index 0000000000..2c8c9978f0 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/AbstractTreeTableModel.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaCacheManager.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaCacheManager.class new file mode 100644 index 0000000000..b471ee310d Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaCacheManager.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaNodeData$NodeType.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaNodeData$NodeType.class new file mode 100644 index 0000000000..b09056ea32 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaNodeData$NodeType.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaNodeData.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaNodeData.class new file mode 100644 index 0000000000..f9c2421e50 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaNodeData.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin$CapaAnalysisTask$1.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin$CapaAnalysisTask$1.class new file mode 100644 index 0000000000..d53945b827 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin$CapaAnalysisTask$1.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin$CapaAnalysisTask.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin$CapaAnalysisTask.class new file mode 100644 index 0000000000..3432485511 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin$CapaAnalysisTask.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin.class new file mode 100644 index 0000000000..fd50585cc6 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaPlugin.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider$1.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider$1.class new file mode 100644 index 0000000000..d8bd728b87 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider$1.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider$2.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider$2.class new file mode 100644 index 0000000000..27bffeaa16 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider$2.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider.class new file mode 100644 index 0000000000..864e66d648 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaProvider.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$Capability$Match.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$Capability$Match.class new file mode 100644 index 0000000000..4bc96441b5 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$Capability$Match.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$Capability.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$Capability.class new file mode 100644 index 0000000000..c91ae90ffb Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$Capability.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$MemoryBlock$Permissions.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$MemoryBlock$Permissions.class new file mode 100644 index 0000000000..131f9c6fae Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$MemoryBlock$Permissions.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$MemoryBlock.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$MemoryBlock.class new file mode 100644 index 0000000000..19d52ec867 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults$MemoryBlock.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults.class new file mode 100644 index 0000000000..6817aa8da0 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResults.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResultsTreeModel.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResultsTreeModel.class new file mode 100644 index 0000000000..077b88fdae Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/CapaResultsTreeModel.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$ListToTreeSelectionModelWrapper.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$ListToTreeSelectionModelWrapper.class new file mode 100644 index 0000000000..2d21d33e6d Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$ListToTreeSelectionModelWrapper.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$TreeTableCellEditor.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$TreeTableCellEditor.class new file mode 100644 index 0000000000..1189770e29 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$TreeTableCellEditor.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$TreeTableCellRenderer.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$TreeTableCellRenderer.class new file mode 100644 index 0000000000..631c6fcb5b Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable$TreeTableCellRenderer.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable.class new file mode 100644 index 0000000000..76d5eeeed5 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/JTreeTable.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModel$TreeTableModelMarker.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModel$TreeTableModelMarker.class new file mode 100644 index 0000000000..6671edd62a Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModel$TreeTableModelMarker.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModel.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModel.class new file mode 100644 index 0000000000..de6064e436 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModel.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter$1.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter$1.class new file mode 100644 index 0000000000..985784aa2e Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter$1.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter$2.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter$2.class new file mode 100644 index 0000000000..e209748144 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter$2.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter.class b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter.class new file mode 100644 index 0000000000..c27e4af3b7 Binary files /dev/null and b/capa/ghidra/plugin/extension/bin/main/capa/ghidra/TreeTableModelAdapter.class differ diff --git a/capa/ghidra/plugin/extension/bin/main/help/TOC_Source.xml b/capa/ghidra/plugin/extension/bin/main/help/TOC_Source.xml new file mode 100644 index 0000000000..a34f62e8f1 --- /dev/null +++ b/capa/ghidra/plugin/extension/bin/main/help/TOC_Source.xml @@ -0,0 +1,57 @@ + + + + + + + diff --git a/capa/ghidra/plugin/extension/bin/main/help/topics/capaexplorer/help.html b/capa/ghidra/plugin/extension/bin/main/help/topics/capaexplorer/help.html new file mode 100644 index 0000000000..1f9d6a1fc7 --- /dev/null +++ b/capa/ghidra/plugin/extension/bin/main/help/topics/capaexplorer/help.html @@ -0,0 +1,23 @@ + + + + + + + + + + + Skeleton Help File for a Module + + + + +

Skeleton Help File for a Module

+ +

This is a simple skeleton help topic. For a better description of what should and should not + go in here, see the "sample" Ghidra extension in the Extensions/Ghidra directory, or see your + favorite help topic. In general, language modules do not have their own help topics.

+ + diff --git a/capa/ghidra/plugin/extension/bin/main/images/README.txt b/capa/ghidra/plugin/extension/bin/main/images/README.txt new file mode 100644 index 0000000000..f20ae77b73 --- /dev/null +++ b/capa/ghidra/plugin/extension/bin/main/images/README.txt @@ -0,0 +1,2 @@ +The "src/resources/images" directory is intended to hold all image/icon files used by +this module. diff --git a/capa/ghidra/plugin/extension/bin/scripts/README.txt b/capa/ghidra/plugin/extension/bin/scripts/README.txt new file mode 100644 index 0000000000..9e408f4be9 --- /dev/null +++ b/capa/ghidra/plugin/extension/bin/scripts/README.txt @@ -0,0 +1 @@ +Java source directory to hold module-specific Ghidra scripts. diff --git a/capa/ghidra/plugin/extension/bin/scripts/RunCapaMVP.py b/capa/ghidra/plugin/extension/bin/scripts/RunCapaMVP.py new file mode 100644 index 0000000000..e6ec0e03eb --- /dev/null +++ b/capa/ghidra/plugin/extension/bin/scripts/RunCapaMVP.py @@ -0,0 +1,93 @@ +import json +import logging +import os +import pathlib +import sys +import traceback + +logger = logging.getLogger("capa_explorer") + +def find_config(): + home = os.path.expanduser("~") + candidates = [ + os.path.join(home, "Library", "ghidra", "ghidra_12.0.3_PUBLIC", "capa_cache", "config.json"), + os.path.join(home, ".ghidra", ".ghidra_12.0.3_PUBLIC", "capa_cache", "config.json"), + ] + env_dir = os.environ.get("GHIDRA_USER_SETTINGS_DIR") + if env_dir: + candidates.insert(0, os.path.join(env_dir, "capa_cache", "config.json")) + + for path in candidates: + if os.path.isfile(path): + with open(path, "r") as f: + return json.load(f) + raise RuntimeError("capa config.json not found") + +def main(): + logging.basicConfig(level=logging.INFO) + print("[RunCapaMVP] Starting capa analysis via PyGhidra...") + + # Initialize GhidraContext with the correct 3 arguments + from capa.features.extractors.ghidra import context as ghidra_ctx_module + from ghidra.program.flatapi import FlatProgramAPI # pylint: disable=import-error + + flat_api = FlatProgramAPI(currentProgram, monitor) + + # Set the module-level singleton using set_context() + ghidra_ctx_module.set_context(currentProgram, flat_api, monitor) + + # Load config + config = find_config() + rules_dir = config.get("rulesDirectory") or config.get("rules_directory") + output_path = config.get("outputPath") + + print("[RunCapaMVP] Rules :", rules_dir) + print("[RunCapaMVP] Output:", output_path) + + # Import Capa modules + import capa.rules + import capa.rules.cache + import capa.ghidra.helpers + import capa.capabilities.common + import capa.render.json as capa_render_json + import capa.features.extractors.ghidra.extractor + + # Run checks + if not capa.ghidra.helpers.is_supported_ghidra_version(): + raise RuntimeError("Unsupported Ghidra version") + if not capa.ghidra.helpers.is_supported_file_type(): + raise RuntimeError("Unsupported file type") + if not capa.ghidra.helpers.is_supported_arch_type(): + raise RuntimeError("Unsupported architecture") + + # Load rules and run analysis + rules_path = pathlib.Path(rules_dir) + print("[RunCapaMVP] Loading rules...") + rules = capa.rules.get_rules([rules_path], cache_dir=None) + meta = capa.ghidra.helpers.collect_metadata([rules_path]) + + print("[RunCapaMVP] Creating GhidraFeatureExtractor...") + extractor = capa.features.extractors.ghidra.extractor.GhidraFeatureExtractor() + + print("[RunCapaMVP] Running capability detection...") + capabilities = capa.capabilities.common.find_capabilities(rules, extractor, False) + + meta.analysis.feature_counts = capabilities.feature_counts + meta.analysis.library_functions = capabilities.library_functions + + print("[RunCapaMVP] Rendering results...") + result_json = capa_render_json.render(meta, rules, capabilities.matches) + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, "w") as f: + f.write(result_json) + + print("[RunCapaMVP] Done — results written to:", output_path) + +try: + main() +except Exception as e: + # Safe error printing + sys.stderr.write(f"[RunCapaMVP] FATAL: {e}\n") + traceback.print_exc() + raise diff --git a/capa/ghidra/plugin/extension/bin/scripts/capa_explorer.py b/capa/ghidra/plugin/extension/bin/scripts/capa_explorer.py new file mode 100644 index 0000000000..cc3ef6f9a9 --- /dev/null +++ b/capa/ghidra/plugin/extension/bin/scripts/capa_explorer.py @@ -0,0 +1,10 @@ +#@author capa +#@category Analysis +#@menupath Tools.Run capa analysis + +program = currentProgram + +print("PyGhidra OK") +print("Program:", program.getName()) +print("Functions:", + program.getFunctionManager().getFunctionCount()) \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/build.gradle b/capa/ghidra/plugin/extension/build.gradle new file mode 100644 index 0000000000..f5d2daf992 --- /dev/null +++ b/capa/ghidra/plugin/extension/build.gradle @@ -0,0 +1,56 @@ +/* ### + * IP: GHIDRA + * + * 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. + */ + +//----------------------START "DO NOT MODIFY" SECTION------------------------------ +def ghidraInstallDir + +if (System.env.GHIDRA_INSTALL_DIR) { + ghidraInstallDir = System.env.GHIDRA_INSTALL_DIR +} +else if (project.hasProperty("GHIDRA_INSTALL_DIR")) { + ghidraInstallDir = project.getProperty("GHIDRA_INSTALL_DIR") +} +else { + ghidraInstallDir = "" +} + +task distributeExtension { + group = "Ghidra" + + apply from: new File(ghidraInstallDir).getCanonicalPath() + "/support/buildExtension.gradle" + dependsOn ':buildExtension' +} +//----------------------END "DO NOT MODIFY" SECTION------------------------------- + +// Include Python scripts in the built extension +buildExtension { + // Include ghidra_scripts directory in the extension + from('ghidra_scripts') { + into 'ghidra_scripts' + } + + // Include data/python directory in the extension (if you have helper scripts there) + from('data/python') { + into 'data/python' + } +} + +// Exclude additional files from the built extension +// Ex: buildExtension.exclude '.idea/**' +buildExtension.exclude '.git/**' +buildExtension.exclude '.gradle/**' +buildExtension.exclude 'build/**' +buildExtension.exclude '.DS_Store' \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/extension.properties b/capa/ghidra/plugin/extension/extension.properties new file mode 100644 index 0000000000..ee393d8a37 --- /dev/null +++ b/capa/ghidra/plugin/extension/extension.properties @@ -0,0 +1,5 @@ +name=@extname@ +description=Finds capabilities in programs, as detected by capa. +author=Mandiant +createdOn=2026-01-26 +version=@extversion@ \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/help/help/TOC_Source.xml b/capa/ghidra/plugin/extension/src/main/help/help/TOC_Source.xml new file mode 100644 index 0000000000..a34f62e8f1 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/help/help/TOC_Source.xml @@ -0,0 +1,57 @@ + + + + + + + diff --git a/capa/ghidra/plugin/extension/src/main/help/help/topics/capaexplorer/help.html b/capa/ghidra/plugin/extension/src/main/help/help/topics/capaexplorer/help.html new file mode 100644 index 0000000000..1f9d6a1fc7 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/help/help/topics/capaexplorer/help.html @@ -0,0 +1,23 @@ + + + + + + + + + + + Skeleton Help File for a Module + + + + +

Skeleton Help File for a Module

+ +

This is a simple skeleton help topic. For a better description of what should and should not + go in here, see the "sample" Ghidra extension in the Extensions/Ghidra directory, or see your + favorite help topic. In general, language modules do not have their own help topics.

+ + diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/AbstractTreeTableModel.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/AbstractTreeTableModel.java new file mode 100644 index 0000000000..d56c9ecda2 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/AbstractTreeTableModel.java @@ -0,0 +1,95 @@ +package capa.ghidra; + +import javax.swing.event.EventListenerList; +import javax.swing.event.TreeModelEvent; +import javax.swing.event.TreeModelListener; +import javax.swing.tree.TreePath; + +/** + * Abstract base class for TreeTableModel implementations. + * Handles listener management and event firing. + */ +public abstract class AbstractTreeTableModel implements TreeTableModel { + + protected Object root; + protected EventListenerList listenerList = new EventListenerList(); + + public AbstractTreeTableModel(Object root) { + this.root = root; + } + + // ------------------------------------------------------------------------- + // TreeModel + // ------------------------------------------------------------------------- + + @Override + public Object getRoot() { + return root; + } + + @Override + public boolean isLeaf(Object node) { + return getChildCount(node) == 0; + } + + @Override + public void valueForPathChanged(TreePath path, Object newValue) { + // not used + } + + @Override + public int getIndexOfChild(Object parent, Object child) { + int count = getChildCount(parent); + for (int i = 0; i < count; i++) { + if (getChild(parent, i).equals(child)) { + return i; + } + } + return -1; + } + + @Override + public void addTreeModelListener(TreeModelListener l) { + listenerList.add(TreeModelListener.class, l); + } + + @Override + public void removeTreeModelListener(TreeModelListener l) { + listenerList.remove(TreeModelListener.class, l); + } + + // ------------------------------------------------------------------------- + // TreeTableModel defaults + // ------------------------------------------------------------------------- + + @Override + public boolean isCellEditable(Object node, int column) { + // Only the tree column (0) is "editable" so the tree can receive events + return getColumnClass(column) == TreeTableModel.class; + } + + @Override + public void setValueAt(Object aValue, Object node, int column) { + // not editable + } + + // ------------------------------------------------------------------------- + // Event helpers + // ------------------------------------------------------------------------- + + protected void fireTreeStructureChanged(Object source, Object[] path, + int[] childIndices, Object[] children) { + TreeModelEvent event = new TreeModelEvent(source, path, childIndices, children); + for (TreeModelListener l : listenerList.getListeners(TreeModelListener.class)) { + l.treeStructureChanged(event); + } + } + + protected void fireTreeNodesChanged(Object source, Object[] path, + int[] childIndices, Object[] children) { + TreeModelEvent event = new TreeModelEvent(source, path, childIndices, children); + for (TreeModelListener l : listenerList.getListeners(TreeModelListener.class)) { + l.treeNodesChanged(event); + } + } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaCacheManager.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaCacheManager.java new file mode 100644 index 0000000000..73954ac020 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaCacheManager.java @@ -0,0 +1,176 @@ +package capa.ghidra; + +import com.google.gson.*; +import ghidra.framework.Application; +import ghidra.program.model.listing.Program; +import ghidra.util.Msg; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public class CapaCacheManager { + + private static final long MAX_CACHE_SIZE = 100L * 1024 * 1024; // 100 MB + private static final String CACHE_DIR_NAME = "capa_cache"; + private static final String CONFIG_FILE = "config.json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + // Cache directory + + private static Path getCacheDir() throws IOException { + File userDir = Application.getUserSettingsDirectory(); + Path cacheDir = userDir.toPath().resolve(CACHE_DIR_NAME); + if (!Files.exists(cacheDir)) { + Files.createDirectories(cacheDir); + setPosixPerms(cacheDir, "rwx------"); + } + return cacheDir; + } + + // Per-binary result cache + + public static String computeProgramHash(Program program) { + try { + String id = program.getName() + "|" + + (program.getExecutablePath() != null ? program.getExecutablePath() : ""); + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(id.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + return String.valueOf(Math.abs(program.getName().hashCode())); + } + } + + private static Path getCacheFilePath(String hash) throws IOException { + if (!hash.matches("^[0-9a-f]+$")) throw new SecurityException("Invalid hash"); + Path dir = getCacheDir(); + Path file = dir.resolve(hash + ".json"); + if (!file.normalize().startsWith(dir.normalize())) + throw new SecurityException("Path traversal detected"); + return file; + } + + public static boolean cacheExists(Program program) { + try { + Path f = getCacheFilePath(computeProgramHash(program)); + return Files.exists(f) && Files.size(f) <= MAX_CACHE_SIZE; + } catch (Exception e) { + return false; + } + } + + public static String readCache(Program program) { + try { + Path f = getCacheFilePath(computeProgramHash(program)); + if (!Files.exists(f) || Files.size(f) > MAX_CACHE_SIZE) return null; + return Files.readString(f, StandardCharsets.UTF_8); + } catch (Exception e) { + Msg.error(CapaCacheManager.class, "readCache failed", e); + return null; + } + } + + public static boolean writeCache(Program program, String json) { + try { + Path file = getCacheFilePath(computeProgramHash(program)); + Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.writeString(tmp, json, StandardCharsets.UTF_8); + setPosixPerms(tmp, "rw-------"); + Files.move(tmp, file, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + return true; + } catch (Exception e) { + Msg.error(CapaCacheManager.class, "writeCache failed", e); + return false; + } + } + + public static boolean deleteCache(Program program) { + try { + Path f = getCacheFilePath(computeProgramHash(program)); + return Files.deleteIfExists(f); + } catch (Exception e) { + Msg.error(CapaCacheManager.class, "deleteCache failed", e); + return false; + } + } + + + public static String getCacheFilePathForPython(Program program) { + try { + return getCacheFilePath(computeProgramHash(program)).toAbsolutePath().toString(); + } catch (Exception e) { + Msg.error(CapaCacheManager.class, "getCacheFilePathForPython failed", e); + return null; + } + } + + // Config — rules directory + private static Path getConfigFilePath() throws IOException { + return getCacheDir().resolve(CONFIG_FILE); + } + + private static JsonObject readConfig() { + try { + Path cfg = getConfigFilePath(); + if (!Files.exists(cfg)) return new JsonObject(); + String raw = Files.readString(cfg, StandardCharsets.UTF_8); + return JsonParser.parseString(raw).getAsJsonObject(); + } catch (Exception e) { + return new JsonObject(); + } + } + + private static void writeConfig(JsonObject config) { + try { + Path cfg = getConfigFilePath(); + Files.writeString(cfg, GSON.toJson(config), StandardCharsets.UTF_8); + } catch (Exception e) { + Msg.error(CapaCacheManager.class, "writeConfig failed", e); + } + } + + public static String readRulesDirectory() { + JsonObject cfg = readConfig(); + // Support both key names for backwards compatibility + for (String key : new String[]{"rulesDirectory", "rules_directory"}) { + if (cfg.has(key) && !cfg.get(key).isJsonNull()) { + String val = cfg.get(key).getAsString().trim(); + if (!val.isEmpty()) return val; + } + } + return null; + } + + public static void writeRulesDirectory(String path) { + JsonObject cfg = readConfig(); + cfg.addProperty("rulesDirectory", path); + // Keep legacy key for any older code + cfg.addProperty("rules_directory", path); + writeConfig(cfg); + } + + public static void writeAnalysisConfig(String rulesDir, String outputPath) { + JsonObject cfg = readConfig(); + cfg.addProperty("rulesDirectory", rulesDir); + cfg.addProperty("rules_directory", rulesDir); + cfg.addProperty("outputPath", outputPath); + writeConfig(cfg); + } + + // Helpers + + private static void setPosixPerms(Path path, String perms) { + try { + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(perms)); + } catch (UnsupportedOperationException | IOException ignored) { + // Windows — no-op + } + } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaNodeData.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaNodeData.java new file mode 100644 index 0000000000..148a71a4f3 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaNodeData.java @@ -0,0 +1,44 @@ +package capa.ghidra; + +/** + * CapaNodeData holds the three column values for a single row in the capa JTreeTable. + * + * Column 0 – Rule Information (shown in the tree column) + * Column 1 – Address (hex address or empty) + * Column 2 – Details (namespace / call detail / operand) + */ +public class CapaNodeData { + + public enum NodeType { + RULE, // top-level rule row e.g. "create HTTP request (2 matches)" + FUNCTION, // function-scope match "function(sub_401880)" + BASIC_BLOCK, // basic block scope "basic block @ 0x…" + STATEMENT, // and / or / optional / not + FEATURE // leaf feature api(...) / string(...) / number(...) / regex(...) + } + + private final String label; // displayed in col-0 tree + private final String address; // col-1 + private final String details; // col-2 + private final NodeType type; + + public CapaNodeData(String label, String address, String details, NodeType type) { + this.label = label != null ? label : ""; + this.address = address != null ? address : ""; + this.details = details != null ? details : ""; + this.type = type; + } + + /** Convenience constructor when type is not critical (statements/features). */ + public CapaNodeData(String label, String address, String details) { + this(label, address, details, NodeType.FEATURE); + } + + public String getLabel() { return label; } + public String getAddress() { return address; } + public String getDetails() { return details; } + public NodeType getNodeType() { return type; } + + @Override + public String toString() { return label; } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaPlugin.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaPlugin.java new file mode 100644 index 0000000000..f31a4cfd82 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaPlugin.java @@ -0,0 +1,226 @@ +package capa.ghidra; + +import ghidra.app.plugin.PluginCategoryNames; +import ghidra.app.plugin.ProgramPlugin; +import ghidra.app.script.GhidraScriptUtil; +import ghidra.app.services.GhidraScriptService; +import ghidra.framework.plugintool.PluginInfo; +import ghidra.framework.plugintool.PluginTool; +import ghidra.framework.plugintool.util.PluginStatus; +import ghidra.program.model.listing.Program; +import ghidra.util.Msg; +import ghidra.util.task.Task; +import ghidra.util.task.TaskLauncher; +import ghidra.util.task.TaskListener; +import ghidra.util.task.TaskMonitor; +import generic.jar.ResourceFile; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +@PluginInfo( + status = PluginStatus.STABLE, + packageName = "Capa", + category = PluginCategoryNames.ANALYSIS, + shortDescription = "capa capability detection", + description = "Integrates Mandiant capa malware capability detection with Ghidra. " + + "All actions are performed from the Capa Explorer window." +) +public class CapaPlugin extends ProgramPlugin { + + private CapaProvider provider; + + // Lifecycle + + public CapaPlugin(PluginTool tool) { + super(tool); + } + + @Override + protected void init() { + super.init(); + provider = new CapaProvider(tool, getName(), this); + } + + @Override + protected void programActivated(Program program) { + provider.onProgramActivated(program); + } + + @Override + protected void programDeactivated(Program program) { + provider.onProgramDeactivated(program); + } + + // Analysis entry-point + + public void runAnalysis(boolean forceRerun) { + Program program = currentProgram; + if (program == null) { + Msg.showInfo(this, null, "Capa Analysis", "No program is currently open."); + return; + } + + String rulesDir = CapaCacheManager.readRulesDirectory(); + if (rulesDir == null || rulesDir.isEmpty()) { + Msg.showInfo(this, null, "Capa Analysis", + "No rules directory configured.\n\nClick Settings and select your capa-rules folder."); + return; + } + + tool.showComponentProvider(provider, true); + + // Load from cache if available and not forcing rerun + if (!forceRerun && CapaCacheManager.cacheExists(program)) { + String json = CapaCacheManager.readCache(program); + if (json != null) { + provider.displayResults(json); + return; + } + } + + TaskLauncher.launch(new CapaAnalysisTask(program, rulesDir)); + } + + // Background analysis task + + + private class CapaAnalysisTask extends Task { + + private final Program program; + private final String rulesDir; + + CapaAnalysisTask(Program program, String rulesDir) { + super("Running capa analysis", true, false, true); + this.program = program; + this.rulesDir = rulesDir; + } + + @Override + public void run(TaskMonitor monitor) { + provider.showLoading("Running capa analysis\u2026"); + + try { + // Determine the cache file path Python will write to + String outputPath = CapaCacheManager.getCacheFilePathForPython(program); + if (outputPath == null) { + showError("Could not determine cache file path."); + return; + } + + // Delete stale cache so we can reliably detect new output + Files.deleteIfExists(Path.of(outputPath)); + + // Write both rulesDir and outputPath into config.json + // so RunCapaMVP.py can read them without recomputing the hash + CapaCacheManager.writeAnalysisConfig(rulesDir, outputPath); + + // Locate RunCapaMVP.py bundled with this extension + String scriptPath = resolveScriptPath(); + if (scriptPath == null) { + showError("Could not locate RunCapaMVP.py in the extension's ghidra_scripts folder."); + return; + } + + // Invoke via GhidraScriptService — executes inside the PyGhidra environment. + // The script receives the current Program context automatically. + monitor.setMessage("Invoking capa via PyGhidra\u2026"); + GhidraScriptService scriptService = tool.getService(GhidraScriptService.class); + if (scriptService == null) { + showError("GhidraScriptService not available. Is PyGhidra installed?"); + return; + } + + // GhidraScriptService requires the script to be in a registered + // script directory. Register our ghidra_scripts folder so it + // can find RunCapaMVP.py by name. + File scriptFile = new File(scriptPath); + ResourceFile scriptDir = new ResourceFile(scriptFile.getParentFile()); + + // Add to known script directories if not already present + if (!GhidraScriptUtil.getScriptSourceDirectories().contains(scriptDir)) { + GhidraScriptUtil.getScriptSourceDirectories().add(scriptDir); + } + + scriptService.runScript("RunCapaMVP.py", new TaskListener() { + @Override + public void taskCompleted(Task task) { + Msg.info(CapaPlugin.this, "[capa] Script completed."); + } + @Override + public void taskCancelled(Task task) { + Msg.warn(CapaPlugin.this, "[capa] Script was cancelled."); + } + }); + + // Poll for the output file written by Python + monitor.setMessage("Waiting for capa results\u2026"); + String json = waitForCacheFile(outputPath, monitor); + if (json == null) { + showError("capa did not produce output. Check the Ghidra console for details."); + return; + } + + // Persist to named cache and display in UI + CapaCacheManager.writeCache(program, json); + provider.displayResults(json); + + } catch (Exception e) { + showError("Unexpected error: " + e.getMessage()); + Msg.error(CapaPlugin.this, "capa analysis error", e); + } + } + + private String waitForCacheFile(String outputPath, TaskMonitor monitor) + throws InterruptedException { + final int MAX_WAIT_MS = 300_000; // 5 min — capa can be slow on large binaries + final int POLL_INTERVAL = 500; + long start = System.currentTimeMillis(); + + while (!monitor.isCancelled()) { + Path p = Path.of(outputPath); + if (Files.exists(p)) { + try { + String raw = Files.readString(p, StandardCharsets.UTF_8).trim(); + // Strip any non-JSON preamble before the opening brace + int jsonStart = raw.indexOf('{'); + if (jsonStart > 0) raw = raw.substring(jsonStart); + if (!raw.isEmpty()) return raw; + } catch (Exception ignored) {} + } + if (System.currentTimeMillis() - start > MAX_WAIT_MS) return null; + Thread.sleep(POLL_INTERVAL); + } + return null; + } + + private String resolveScriptPath() { + try { + File jar = new File( + getClass().getProtectionDomain().getCodeSource().getLocation().toURI()); + // Extension layout: CapaExplorer/lib/CapaExplorer.jar + // CapaExplorer/ghidra_scripts/RunCapaMVP.py + File extensionDir = jar.getParentFile().getParentFile(); + File[] candidates = { + new File(extensionDir, "ghidra_scripts/RunCapaMVP.py"), + new File(jar.getParentFile(), "ghidra_scripts/RunCapaMVP.py"), + new File(extensionDir, "ghidra_Scripts/RunCapaMVP.py"), + }; + for (File f : candidates) { + Msg.info(CapaPlugin.this, "Checking script path: " + f.getAbsolutePath()); + if (f.exists()) return f.getAbsolutePath(); + } + } catch (Exception e) { + Msg.warn(CapaPlugin.this, "Could not resolve script path: " + e.getMessage()); + } + return null; + } + + private void showError(String msg) { + provider.showError(msg); + Msg.showError(CapaPlugin.this, null, "Capa Analysis Error", msg); + } + } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaProvider.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaProvider.java new file mode 100644 index 0000000000..00a101fedd --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaProvider.java @@ -0,0 +1,414 @@ +package capa.ghidra; + +import com.google.gson.*; +import docking.ComponentProvider; +import docking.widgets.filechooser.GhidraFileChooser; +import docking.widgets.filechooser.GhidraFileChooserMode; +import ghidra.app.services.GoToService; +import ghidra.framework.plugintool.PluginTool; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Program; +import ghidra.util.Msg; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; + +/** + * CapaProvider — the main Ghidra component window for the capa extension. + * + * UI mirrors the IDA FLARE capa explorer: + * ┌──────────────────────────────────────────────────────┐ + * │ [✓] Limit results to current function [✓] Show matches by function │ + * │ search... │ + * ├──────────────────────────────────────────────────────┤ + * │ Rule Information │ Address │ Details │ + * │ ▶ create HTTP request… │ │ comm/http/… │ + * │ ▼ function(sub_401880) │ 00401880│ │ + * │ and │ │ │ + * │ api(shell32.…) │ 00401981│ call ds:… │ + * ├──────────────────────────────────────────────────────┤ + * │ [Analyze] [Reset] [Settings] [Save] │ + * │ capa rules directory: /path/to/rules (N rules) │ + * └──────────────────────────────────────────────────────┘ + */ +public class CapaProvider extends ComponentProvider { + + // ------------------------------------------------------------------ // + // State // + // ------------------------------------------------------------------ // + + private final CapaPlugin plugin; + private Program currentProgram; + + // UI references + private JPanel mainPanel; + private JCheckBox limitToFunctionCheckbox; + private JCheckBox showByFunctionCheckbox; + private JTextField searchField; + private JTreeTable treeTable; + private CapaResultsTreeModel treeTableModel; + private JScrollPane scrollPane; + + private JButton analyzeButton; + private JButton resetButton; + private JButton settingsButton; + + private JLabel statusLabel; + private JLabel rulesPathLabel; + + // ------------------------------------------------------------------ // + // Constructor // + // ------------------------------------------------------------------ // + + public CapaProvider(PluginTool tool, String owner, CapaPlugin plugin) { + super(tool, "Capa Explorer", owner); + this.plugin = plugin; + buildUI(); + setVisible(true); + } + + // ------------------------------------------------------------------ // + // ComponentProvider API // + // ------------------------------------------------------------------ // + + @Override + public JComponent getComponent() { + return mainPanel; + } + + // ------------------------------------------------------------------ // + // UI construction // + // ------------------------------------------------------------------ // + + private void buildUI() { + mainPanel = new JPanel(new BorderLayout(0, 0)); + mainPanel.setPreferredSize(new Dimension(860, 500)); + + mainPanel.add(buildTopPanel(), BorderLayout.NORTH); + mainPanel.add(buildTreePanel(), BorderLayout.CENTER); + mainPanel.add(buildBottomPanel(), BorderLayout.SOUTH); + } + + /** Checkboxes + search bar */ + private JPanel buildTopPanel() { + JPanel top = new JPanel(new BorderLayout(4, 2)); + top.setBorder(BorderFactory.createEmptyBorder(4, 6, 2, 6)); + + // Checkbox row + JPanel checkboxRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 12, 0)); + limitToFunctionCheckbox = new JCheckBox("Limit results to current function"); + showByFunctionCheckbox = new JCheckBox("Show matches by function"); + limitToFunctionCheckbox.setFocusable(false); + showByFunctionCheckbox.setFocusable(false); + + limitToFunctionCheckbox.addActionListener(e -> refreshFilter()); + showByFunctionCheckbox.addActionListener(e -> refreshFilter()); + + checkboxRow.add(limitToFunctionCheckbox); + checkboxRow.add(showByFunctionCheckbox); + + // Search row + searchField = new JTextField(); + searchField.putClientProperty("JTextField.placeholderText", "search..."); + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override public void insertUpdate(DocumentEvent e) { refreshFilter(); } + @Override public void removeUpdate(DocumentEvent e) { refreshFilter(); } + @Override public void changedUpdate(DocumentEvent e) { refreshFilter(); } + }); + + top.add(checkboxRow, BorderLayout.NORTH); + top.add(searchField, BorderLayout.CENTER); + return top; + } + + /** The JTreeTable in a scroll pane */ + private JScrollPane buildTreePanel() { + treeTableModel = new CapaResultsTreeModel(); + treeTable = new JTreeTable(treeTableModel); + + treeTable.setAutoResizeMode(JTable.AUTO_RESIZE_LAST_COLUMN); + treeTable.getColumnModel().getColumn(0).setPreferredWidth(380); + treeTable.getColumnModel().getColumn(1).setPreferredWidth(110); + treeTable.getColumnModel().getColumn(2).setPreferredWidth(300); + + // Double-click on Address column navigates to that location + // Attach listener to the JTreeTable for double-click navigation on Address column + treeTable.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + navigateToSelected(e); + } + } + }); + + scrollPane = new JScrollPane(treeTable); + return scrollPane; + } + + /** Analyze / Reset / Settings buttons + status labels */ + private JPanel buildBottomPanel() { + JPanel bottom = new JPanel(new BorderLayout(0, 2)); + bottom.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6)); + + // Button row + JPanel buttonRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + + analyzeButton = new JButton("Analyze"); + resetButton = new JButton("Reset"); + settingsButton = new JButton("Settings"); + + analyzeButton.addActionListener(e -> onAnalyze()); + resetButton.addActionListener(e -> onReset()); + settingsButton.addActionListener(e -> onSettings()); + + buttonRow.add(analyzeButton); + buttonRow.add(resetButton); + buttonRow.add(settingsButton); + + // Status row + statusLabel = new JLabel(" "); + rulesPathLabel = new JLabel(" "); + rulesPathLabel.setFont(rulesPathLabel.getFont().deriveFont(Font.PLAIN, 11f)); + rulesPathLabel.setForeground(Color.GRAY); + + updateRulesPathLabel(); + + JPanel statusRow = new JPanel(new GridLayout(2, 1, 0, 0)); + statusRow.add(statusLabel); + statusRow.add(rulesPathLabel); + + bottom.add(buttonRow, BorderLayout.NORTH); + bottom.add(statusRow, BorderLayout.SOUTH); + return bottom; + } + + // ------------------------------------------------------------------ // + // Button handlers // + // ------------------------------------------------------------------ // + + private void onAnalyze() { + if (currentProgram == null) { + Msg.showInfo(this, mainPanel, "Capa Analysis", "No program is currently open."); + return; + } + plugin.runAnalysis(false); + } + + private void onReset() { + clearResults(); + setStatus("Results cleared."); + } + + private void onSettings() { + GhidraFileChooser chooser = new GhidraFileChooser(mainPanel); + chooser.setTitle("Select capa rules directory"); + chooser.setApproveButtonText("Select"); + chooser.setFileSelectionMode(GhidraFileChooserMode.DIRECTORIES_ONLY); + + // Pre-select existing path if any + String existing = CapaCacheManager.readRulesDirectory(); + if (existing != null && !existing.isEmpty()) { + chooser.setCurrentDirectory(new File(existing)); + } + + File selected = chooser.getSelectedFile(); + chooser.dispose(); + + if (selected != null) { + CapaCacheManager.writeRulesDirectory(selected.getAbsolutePath()); + updateRulesPathLabel(); + setStatus("Rules directory updated."); + } + } + + // ------------------------------------------------------------------ // + // Public display API (called from CapaPlugin / analysis task) // + // ------------------------------------------------------------------ // + + public void showLoading(String message) { + SwingUtilities.invokeLater(() -> { + setStatus(message); + analyzeButton.setEnabled(false); + }); + } + + public void showError(String message) { + SwingUtilities.invokeLater(() -> { + setStatus("Error: " + message); + analyzeButton.setEnabled(true); + }); + } + + public void displayResults(String capaJson) { + SwingUtilities.invokeLater(() -> { + try { + JsonObject json = JsonParser.parseString(capaJson).getAsJsonObject(); + treeTableModel = CapaResultsTreeModel.fromJson(json); + treeTable.setModel(new TreeTableModelAdapter(treeTableModel, treeTable.getTree())); + treeTable.getTree().setModel(treeTableModel); + + // Column widths after model swap + treeTable.getColumnModel().getColumn(0).setPreferredWidth(380); + treeTable.getColumnModel().getColumn(1).setPreferredWidth(110); + treeTable.getColumnModel().getColumn(2).setPreferredWidth(300); + + // Count top-level rules + int ruleCount = treeTableModel.getChildCount(treeTableModel.getRoot()); + // Subtract 1 if root itself is the "capa results" invisible root + setStatus("Analysis complete — " + ruleCount + " rule(s) matched."); + } catch (Exception e) { + showError("Failed to parse results: " + e.getMessage()); + Msg.error(this, "JSON parse error", e); + } + analyzeButton.setEnabled(true); + }); + } + + public void clearResults() { + SwingUtilities.invokeLater(() -> { + treeTableModel = new CapaResultsTreeModel(); + treeTable.setModel(new TreeTableModelAdapter(treeTableModel, treeTable.getTree())); + treeTable.getTree().setModel(treeTableModel); + analyzeButton.setEnabled(true); + }); + } + + // ------------------------------------------------------------------ // + // Program tracking // + // ------------------------------------------------------------------ // + + public void onProgramActivated(Program program) { + this.currentProgram = program; + clearResults(); + setStatus(program != null ? "Ready — click Analyze to run capa." : "No program open."); + } + + public void onProgramDeactivated(Program program) { + this.currentProgram = null; + clearResults(); + setStatus("No program open."); + } + + // ------------------------------------------------------------------ // + // Navigation (double-click on address cell) // + // ------------------------------------------------------------------ // + + /** + * Navigate to the address when user double-clicks the Address column. + * This replicates the behavior of the capa IDA plugin. + */ + private void navigateToSelected(MouseEvent e) { + // Get the row and column at the click point using table coordinates + int row = treeTable.rowAtPoint(e.getPoint()); + int column = treeTable.columnAtPoint(e.getPoint()); + + Msg.info(this, "Double-click detected at point: " + e.getPoint()); + Msg.info(this, "Row index: " + row + ", Column index: " + column); + + // Only navigate when clicking the Address column (column 1) + if (row < 0 || column != 1) { + Msg.info(this, "Ignoring click: row=" + row + " is invalid or column=" + column + " is not Address column"); + return; + } + + // Read the address string from the Address column (column 1) + Object addressVal = treeTable.getValueAt(row, 1); + Msg.info(this, "Address value at [" + row + ", 1]: " + addressVal + + " (type: " + (addressVal != null ? addressVal.getClass().getName() : "null") + ")"); + + if (addressVal == null || !(addressVal instanceof String)) { + Msg.info(this, "Address value is not a string or is null"); + return; + } + + String addrStr = ((String) addressVal).trim(); + if (addrStr.isEmpty()) { + Msg.info(this, "Address string is empty after trim"); + return; + } + + Msg.info(this, "Attempting to navigate to address: " + addrStr); + + try { + // Parse hex address (handles both "0x4019F0" and "4019F0" formats) + long offset = Long.parseUnsignedLong( + addrStr.startsWith("0x") || addrStr.startsWith("0X") + ? addrStr.substring(2) : addrStr, 16); + + Msg.info(this, "Successfully parsed offset: 0x" + Long.toHexString(offset)); + + // Navigate using Ghidra's GoToService + GoToService goToService = dockingTool.getService(GoToService.class); + if (goToService == null) { + Msg.error(this, "GoToService is null - cannot navigate"); + return; + } + if (currentProgram == null) { + Msg.error(this, "currentProgram is null - cannot navigate"); + return; + } + + Address addr = currentProgram.getAddressFactory() + .getDefaultAddressSpace().getAddress(offset); + + Msg.info(this, "Created Ghidra Address object: " + addr); + goToService.goTo(addr); + Msg.info(this, "Navigation successful to " + addr); + } catch (NumberFormatException ex) { + Msg.error(this, "Failed to parse address '" + addrStr + "' as hex", ex); + } catch (Exception ex) { + Msg.error(this, "Navigation failed", ex); + } + } + + // ------------------------------------------------------------------ // + // Filter (search / checkboxes) — placeholder, expand as needed // + // ------------------------------------------------------------------ // + + private void refreshFilter() { + // TODO: implement live filtering of tree rows based on searchField text + // and the two checkboxes. For now just a no-op so the UI is wired. + } + + // ------------------------------------------------------------------ // + // Helpers // + // ------------------------------------------------------------------ // + + private void setStatus(String msg) { + statusLabel.setText(msg); + } + + private void updateRulesPathLabel() { + String rulesDir = CapaCacheManager.readRulesDirectory(); + if (rulesDir == null || rulesDir.isEmpty()) { + rulesPathLabel.setText("capa rules directory: (not configured — click Settings)"); + } else { + // Count yml files in root to mimic "474 rules" + File dir = new File(rulesDir); + int ruleCount = countYmlFiles(dir); + rulesPathLabel.setText("capa rules directory: " + rulesDir + + (ruleCount > 0 ? " (" + ruleCount + " rules)" : "")); + } + } + + private int countYmlFiles(File dir) { + if (dir == null || !dir.isDirectory()) return 0; + int count = 0; + File[] files = dir.listFiles(); + if (files == null) return 0; + for (File f : files) { + if (f.isDirectory()) { + count += countYmlFiles(f); + } else if (f.getName().endsWith(".yml") || f.getName().endsWith(".yaml")) { + count++; + } + } + return count; + } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaResults.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaResults.java new file mode 100644 index 0000000000..4aef58f265 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaResults.java @@ -0,0 +1,84 @@ +package capa.ghidra; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import java.util.ArrayList; +import java.util.List; + +/** + * Legacy data class - kept for cache compatibility. + * New code uses CapaTreeTableModel to parse JSON directly. + */ +public class CapaResults { + + private static final Gson gson = new Gson(); + + public String programName; + public String programPath; + public String imageBase; + public String language; + public String compiler; + + public int functionCount; + public int externalFunctionCount; + public String timestamp; + public String capaVersion; + public String programHash; + + public List memoryBlocks; + public List capabilities; + + public CapaResults() { + this.memoryBlocks = new ArrayList<>(); + this.capabilities = new ArrayList<>(); + } + + public static CapaResults fromJson(String json) throws JsonSyntaxException { + try { + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + if (root.has("results")) { + return gson.fromJson(root.getAsJsonObject("results"), CapaResults.class); + } + return gson.fromJson(json, CapaResults.class); + } catch (Exception e) { + throw new JsonSyntaxException("Failed to parse capa results: " + e.getMessage(), e); + } + } + + public String toJson() { + return gson.toJson(this); + } + + public static class MemoryBlock { + public String name; + public String start; + public String end; + public long size; + public Permissions permissions; + + public static class Permissions { + public boolean read; + public boolean write; + public boolean execute; + } + } + + public static class Capability { + public String name; + public String namespace; + public String description; + public List matches; + + public Capability() { + this.matches = new ArrayList<>(); + } + + public static class Match { + public String address; + public String function; + public String details; + } + } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaResultsTreeModel.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaResultsTreeModel.java new file mode 100644 index 0000000000..8f9ef7e686 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/CapaResultsTreeModel.java @@ -0,0 +1,532 @@ +package capa.ghidra; + +import com.google.gson.*; +import javax.swing.tree.DefaultMutableTreeNode; +import java.util.*; + +/** + * CapaTreeTableModel builds a JTreeTable-compatible model from capa v7+ JSON. + * + * Tree structure mirrors the IDA plugin: + * Rule name (N matches) | | namespace/path + * function(sub_XXXX) | 0xADDR | + * and | | + * api(foo.Bar) | 0xADDR | call foo.Bar + * ... + * basic block @ 0xADDR | | + * or | | + * number: 0x26 | 0xADDR | mov eax, 0x26 + */ +public class CapaResultsTreeModel extends AbstractTreeTableModel { + + private static final String[] COLUMN_NAMES = {"Rule Information", "Address", "Details"}; + private static final Class[] COLUMN_TYPES = {TreeTableModel.class, String.class, String.class}; + + // ------------------------------------------------------------------ // + // Construction // + // ------------------------------------------------------------------ // + + public CapaResultsTreeModel() { + super(new DefaultMutableTreeNode(new CapaNodeData("(no results)", "", ""))); + } + + private CapaResultsTreeModel(DefaultMutableTreeNode root) { + super(root); + } + + public static CapaResultsTreeModel fromJson(JsonObject capaJson) { + return new CapaResultsTreeModel(buildTree(capaJson)); + } + + // ------------------------------------------------------------------ // + // TreeTableModel column API // + // ------------------------------------------------------------------ // + + @Override public int getColumnCount() { return COLUMN_NAMES.length; } + @Override public String getColumnName(int col) { return COLUMN_NAMES[col]; } + @Override public Class getColumnClass(int col) { return COLUMN_TYPES[col]; } + + @Override + public Object getValueAt(Object node, int column) { + DefaultMutableTreeNode treeNode = (DefaultMutableTreeNode) node; + CapaNodeData data = (CapaNodeData) treeNode.getUserObject(); + switch (column) { + case 0: return data; // rendered by JTree column + case 1: return data.getAddress(); + case 2: return data.getDetails(); + default: return null; + } + } + + // ------------------------------------------------------------------ // + // TreeModel node API // + // ------------------------------------------------------------------ // + + @Override public Object getChild(Object parent, int index) { + return ((DefaultMutableTreeNode) parent).getChildAt(index); + } + + @Override public int getChildCount(Object parent) { + return ((DefaultMutableTreeNode) parent).getChildCount(); + } + + @Override public boolean isLeaf(Object node) { + return ((DefaultMutableTreeNode) node).isLeaf(); + } + + // ------------------------------------------------------------------ // + // JSON → Tree builder // + // ------------------------------------------------------------------ // + + private static DefaultMutableTreeNode buildTree(JsonObject capaJson) { + DefaultMutableTreeNode root = + new DefaultMutableTreeNode(new CapaNodeData("capa results", "", "")); + + try { + // capa cache wrapper: { "version", "timestamp", "programHash", "programName", "results": { "rules": {...} } } + JsonObject payload = capaJson; + if (capaJson.has("results") && capaJson.get("results").isJsonObject()) { + payload = capaJson.getAsJsonObject("results"); + } + + JsonObject rules = null; + for (String key : new String[]{"rules", "rule_matches", "capabilities"}) { + if (payload.has(key) && payload.get(key).isJsonObject()) { + rules = payload.getAsJsonObject(key); + break; + } + } + + if (rules == null) { + StringBuilder keys = new StringBuilder("Top-level keys: "); + for (String k : payload.keySet()) keys.append(k).append(", "); + root.add(new DefaultMutableTreeNode( + new CapaNodeData(keys.toString(), "", ""))); + return root; + } + + for (Map.Entry ruleEntry : rules.entrySet()) { + String ruleName = ruleEntry.getKey(); + if (!ruleEntry.getValue().isJsonObject()) continue; + JsonObject ruleObj = ruleEntry.getValue().getAsJsonObject(); + + JsonObject meta = ruleObj.has("meta") + ? ruleObj.getAsJsonObject("meta") : new JsonObject(); + + String namespace = getStringOr(meta, "namespace", ""); + + List matchList = collectMatches(ruleObj); + + int matchCount = matchList.size(); + if (matchCount == 0) continue; + + String label = matchCount > 1 + ? ruleName + " (" + matchCount + " matches)" + : ruleName; + + DefaultMutableTreeNode ruleNode = new DefaultMutableTreeNode( + new CapaNodeData(label, "", namespace, CapaNodeData.NodeType.RULE)); + + for (JsonObject match : matchList) { + addMatchNode(ruleNode, match); + } + + root.add(ruleNode); + } + } catch (Exception e) { + root.add(new DefaultMutableTreeNode( + new CapaNodeData("Error parsing results: " + e.getMessage(), "", ""))); + } + + return root; + } + + /** + * Collect all match objects from a rule, handling both capa JSON formats: + * v6: "matches": [[location, matchDetail], ...] (array of pairs) + * v7+: "matches": { "0x1234": matchDetail, ... } (object keyed by address) + */ + private static List collectMatches(JsonObject ruleObj) { + List result = new ArrayList<>(); + if (!ruleObj.has("matches")) return result; + + JsonElement matchesEl = ruleObj.get("matches"); + + if (matchesEl.isJsonArray()) { + // v6 format: array of [location, detail] pairs + for (JsonElement el : matchesEl.getAsJsonArray()) { + if (!el.isJsonArray()) continue; + JsonArray pair = el.getAsJsonArray(); + if (pair.size() < 2 || !pair.get(1).isJsonObject()) continue; + JsonObject detail = pair.get(1).getAsJsonObject(); + // Inject location into detail for address extraction + detail.add("_location", pair.get(0)); + result.add(detail); + } + } else if (matchesEl.isJsonObject()) { + // v7+ format: object keyed by address string + JsonObject matchesObj = matchesEl.getAsJsonObject(); + for (Map.Entry entry : matchesObj.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject detail = entry.getValue().getAsJsonObject().deepCopy(); + // Inject the address key as "_addr" for display + detail.addProperty("_addr", entry.getKey()); + result.add(detail); + } + } + return result; + } + + private static void addMatchNode(DefaultMutableTreeNode ruleNode, JsonObject match) { + // Determine address and scope label + String addr = ""; + if (match.has("_addr")) { + addr = match.get("_addr").getAsString(); + // Normalise: if it looks like a plain integer, hex-format it + try { + long v = Long.parseUnsignedLong(addr); + addr = "0x" + Long.toHexString(v).toUpperCase(); + } catch (NumberFormatException ignored) {} + } else if (match.has("_location")) { + addr = extractAddress(match.get("_location")); + } + + // Determine scope type from "node" or presence of sub-matches + String scopeLabel; + boolean isFunction = match.has("node") && + match.getAsJsonObject("node").has("type") && + "function".equals(match.getAsJsonObject("node").get("type").getAsString()); + + if (addr.isEmpty()) { + scopeLabel = "file scope"; + } else if (isFunction) { + scopeLabel = "function @ " + addr; + } else { + scopeLabel = "basic block @ " + addr; + } + + CapaNodeData.NodeType scopeType = isFunction + ? CapaNodeData.NodeType.FUNCTION : CapaNodeData.NodeType.BASIC_BLOCK; + + DefaultMutableTreeNode scopeNode = new DefaultMutableTreeNode( + new CapaNodeData(scopeLabel, addr, "", scopeType)); + + // Build statement/feature sub-tree from "node" - pass the scope address for propagation + // IMPORTANT: Capture the returned node to use as parent for match.children + DefaultMutableTreeNode topLevelNode = null; + if (match.has("node")) { + topLevelNode = buildStatementTree(scopeNode, match.getAsJsonObject("node"), match, addr); + } + + // Handle "children" - render them recursively as nested Match objects + // Critical: Use topLevelNode (if not null) as parent for children, not scopeNode + // This ensures children nest under their parent statement, not as siblings + if (match.has("children") && match.get("children").isJsonArray()) { + DefaultMutableTreeNode parentForChildren = (topLevelNode != null) ? topLevelNode : scopeNode; + for (JsonElement child : match.getAsJsonArray("children")) { + if (child.isJsonObject()) { + JsonObject childMatch = child.getAsJsonObject(); + // Recursively render child Match under the correct parent node + // If match.node created a statement, children nest under that statement + // If match.node was only a feature, children nest under scope + addMatchNodeRecursive(parentForChildren, childMatch, addr); + } + } + } + + ruleNode.add(scopeNode); + } + + /** + * Recursively process a Match object, respecting its node/children hierarchy. + * Used for rendering nested matches that should be children of a parent statement. + * + * @param parent the parent tree node to add this match's nodes under + * @param match the Match object to render + * @param scopeAddress the address context from the parent scope + */ + private static void addMatchNodeRecursive(DefaultMutableTreeNode parent, JsonObject match, String scopeAddress) { + if (!match.has("node")) return; + + // Process this match's node (statement or feature) and get the node that was created + DefaultMutableTreeNode nodeJustCreated = buildStatementTree(parent, match.getAsJsonObject("node"), match, scopeAddress); + + // Recursively process children - they should nest under the node we just created, not as siblings + if (nodeJustCreated != null && match.has("children") && match.get("children").isJsonArray()) { + for (JsonElement child : match.getAsJsonArray("children")) { + if (child.isJsonObject()) { + // Critical fix: pass nodeJustCreated (not parent) as the parent for children + // This ensures nesting: each child statement becomes a child of the parent statement + addMatchNodeRecursive(nodeJustCreated, child.getAsJsonObject(), scopeAddress); + } + } + } + } + + // ------------------------------------------------------------------ // + // Statement / feature recursive builder // + // ------------------------------------------------------------------ // + + private static DefaultMutableTreeNode buildStatementTree(DefaultMutableTreeNode parent, + JsonObject node, + JsonObject matchDetail, + String scopeAddress) { + if (node == null) return null; + + String nodeType = getStringOr(node, "type", ""); + + if ("statement".equals(nodeType)) { + JsonObject stmt = node.has("statement") ? node.getAsJsonObject("statement") : node; + String stmtType = getStringOr(stmt, "type", "and").toLowerCase(); + + // Statement nodes inherit address from their scope (function/basicblock) + DefaultMutableTreeNode stmtNode = new DefaultMutableTreeNode( + new CapaNodeData(stmtType, scopeAddress, "", CapaNodeData.NodeType.STATEMENT)); + + // Recurse into children - get children from the STATEMENT node itself, not matchDetail + JsonArray children = stmt.has("children") + ? stmt.getAsJsonArray("children") : new JsonArray(); + for (JsonElement child : children) { + if (!child.isJsonObject()) continue; + JsonObject childObj = child.getAsJsonObject(); + if (childObj.has("node")) { + // Capture the returned node in case it's a statement that could have further children + // This ensures proper nesting of nested statements + DefaultMutableTreeNode childNode = buildStatementTree(stmtNode, + childObj.getAsJsonObject("node"), matchDetail, scopeAddress); + // childNode contains the created statement/feature node for further processing if needed + } + } + + parent.add(stmtNode); + return stmtNode; // Return the statement node so it can be used as parent for Match children + + } else if ("feature".equals(nodeType)) { + JsonObject feature = node.has("feature") ? node.getAsJsonObject("feature") : node; + + // Extract feature's own address from the node's location field (replaces scope address) + String featureAddress = extractAddress(node.get("location")); + // If feature has no explicit location, fall back to scope address + if (featureAddress.isEmpty()) { + featureAddress = scopeAddress; + } + + addFeatureNode(parent, feature, matchDetail, featureAddress); + return null; // Features are leaves, return null + } + return null; + } + + private static void addFeatureNode(DefaultMutableTreeNode parent, + JsonObject feature, + JsonObject matchDetail, + String featureAddress) { + String featureLabel = renderFeature(feature); + String details = buildFeatureDetails(feature, matchDetail); + + // Create feature node with its own address (extracted from feature's location in buildStatementTree) + parent.add(new DefaultMutableTreeNode( + new CapaNodeData(featureLabel, featureAddress, details))); + } + + + /** + * Build contextual details for a feature based on its type. + * Returns strings like: + * "call CreateFileA" + * "\"cmd.exe\"" + * "mov eax, 0x26" + */ + private static String buildFeatureDetails(JsonObject feature, JsonObject matchDetail) { + String type = getStringOr(feature, "type", ""); + + // For api features, try to show call site context + if ("api".equals(type)) { + if (matchDetail.has("captures")) { + JsonObject captures = matchDetail.getAsJsonObject("captures"); + if (captures.has("call")) { + return "call " + captures.get("call").getAsString(); + } + } + // Extract just the function name for display + String apiName = getStringOr(feature, "api", ""); + if (!apiName.isEmpty()) { + return "call " + apiName; + } + } + + // For string features, show the string value with quotes + if ("string".equals(type)) { + String strValue = getStringOr(feature, "string", ""); + if (!strValue.isEmpty()) { + return "\"" + strValue + "\""; + } + } + + // For number features, show the numeric value + if ("number".equals(type)) { + String numValue = getStringOr(feature, "number", ""); + if (!numValue.isEmpty()) { + // Try to show context like "mov eax, 0x26" + if (matchDetail.has("captures")) { + JsonObject captures = matchDetail.getAsJsonObject("captures"); + if (captures.size() > 0) { + // Get first capture as context + for (String key : captures.keySet()) { + String context = captures.get(key).getAsString(); + if (!context.isEmpty()) { + return context; + } + } + } + } + return numValue; + } + } + + // For mnemonic features, show the instruction + if ("mnemonic".equals(type)) { + String mnem = getStringOr(feature, "mnemonic", ""); + if (!mnem.isEmpty()) { + // Try to show full instruction from captures + if (matchDetail.has("captures")) { + JsonObject captures = matchDetail.getAsJsonObject("captures"); + for (String key : captures.keySet()) { + String instr = captures.get(key).getAsString(); + if (!instr.isEmpty()) { + return instr; + } + } + } + return mnem; + } + } + + // For regex features, show the regex pattern + if ("regex".equals(type)) { + String regexVal = getStringOr(feature, "regex", ""); + if (!regexVal.isEmpty()) { + return regexVal; + } + } + + // For bytes features, show hex dump + if ("bytes".equals(type)) { + String bytesVal = getStringOr(feature, "bytes", ""); + if (!bytesVal.isEmpty()) { + return bytesVal; + } + } + + // For characteristic, show the characteristic name + if ("characteristic".equals(type)) { + String charVal = getStringOr(feature, "characteristic", ""); + if (!charVal.isEmpty()) { + return charVal; + } + } + + // For import/export/section, show the name + if ("import".equals(type) || "export".equals(type) || "section".equals(type)) { + String val = getStringOr(feature, type, ""); + if (!val.isEmpty()) { + return val; + } + } + + // Fallback: try to find any meaningful capture + if (matchDetail.has("captures")) { + JsonObject captures = matchDetail.getAsJsonObject("captures"); + for (String key : captures.keySet()) { + String val = captures.get(key).getAsString(); + if (!val.isEmpty() && val.length() < 100) { + return val; + } + } + } + + return ""; + } + + // ------------------------------------------------------------------ // + // Helpers // + // ------------------------------------------------------------------ // + /** + * Render a feature using capa's canonical string representation. + * This mimics the Python Feature.__str__() output. + */ + private static String renderFeature(JsonObject feature) { + + String type = getStringOr(feature, "type", ""); + + switch (type) { + + case "api": + return "api(" + getStringOr(feature, "api", "") + ")"; + + case "string": + return "string(\"" + getStringOr(feature, "string", "") + "\")"; + + case "number": + return "number(" + getStringOr(feature, "number", "") + ")"; + + case "regex": + return "regex(" + getStringOr(feature, "regex", "") + ")"; + + case "mnemonic": + return "mnemonic(" + getStringOr(feature, "mnemonic", "") + ")"; + + case "characteristic": + return "characteristic(" + + getStringOr(feature, "characteristic", "") + ")"; + + case "import": + return "import(" + getStringOr(feature, "import", "") + ")"; + + case "export": + return "export(" + getStringOr(feature, "export", "") + ")"; + + case "section": + return "section(" + getStringOr(feature, "section", "") + ")"; + + case "bytes": + return "bytes(" + getStringOr(feature, "bytes", "") + ")"; + + case "offset": + return "offset(" + getStringOr(feature, "offset", "") + ")"; + + default: + if (feature.has("value")) { + return type + "(" + feature.get("value").getAsString() + ")"; + } + return type; + } + } + private static String extractAddress(JsonElement locEl) { + if (locEl == null || locEl.isJsonNull()) return ""; + try { + if (locEl.isJsonObject()) { + JsonObject loc = locEl.getAsJsonObject(); + if (loc.has("value")) { + long val = loc.get("value").getAsLong(); + return "0x" + Long.toHexString(val).toUpperCase(); + } + } + // plain string address + if (locEl.isJsonPrimitive()) { + String s = locEl.getAsString(); + if (s.startsWith("0x") || s.startsWith("0X")) return s.toUpperCase(); + long val = Long.parseUnsignedLong(s); + return "0x" + Long.toHexString(val).toUpperCase(); + } + } catch (Exception ignored) {} + return ""; + } + + private static String getStringOr(JsonObject obj, String key, String fallback) { + if (obj != null && obj.has(key) && !obj.get(key).isJsonNull()) { + try { return obj.get(key).getAsString(); } catch (Exception ignored) {} + } + return fallback; + } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/JTreeTable.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/JTreeTable.java new file mode 100644 index 0000000000..d35dffb550 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/JTreeTable.java @@ -0,0 +1,311 @@ +package capa.ghidra; + +import javax.swing.AbstractCellEditor; +import javax.swing.JTable; +import javax.swing.JTree; +import javax.swing.SwingConstants; +import javax.swing.UIManager; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.TableCellEditor; +import javax.swing.table.TableCellRenderer; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeSelectionModel; +import javax.swing.tree.TreeCellRenderer; +import javax.swing.tree.TreeModel; +import javax.swing.tree.TreePath; +import javax.swing.ListSelectionModel; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.event.MouseEvent; +import java.util.EventObject; + +/** + * JTreeTable - a JTable that renders its first column as a JTree. + * Based on the Sun Swing TreeTable example, adapted for Ghidra/capa. + */ +public class JTreeTable extends JTable { + + protected TreeTableCellRenderer tree; + + public JTreeTable(TreeTableModel treeTableModel) { + super(); + + // Create the tree. It will be used as a renderer and editor. + tree = new TreeTableCellRenderer(treeTableModel); + + // Install a tableModel representing the visible rows in the tree. + super.setModel(new TreeTableModelAdapter(treeTableModel, tree)); + + // Force the JTable and JTree to share their row selection models. + ListToTreeSelectionModelWrapper selectionWrapper = + new ListToTreeSelectionModelWrapper(); + tree.setSelectionModel(selectionWrapper); + setSelectionModel(selectionWrapper.getListSelectionModel()); + + // Install the tree editor renderer and editor. + setDefaultRenderer(TreeTableModel.class, tree); + setDefaultEditor(TreeTableModel.class, new TreeTableCellEditor()); + + // No grid lines - looks cleaner like IDA + setShowGrid(false); + setIntercellSpacing(new Dimension(0, 0)); + + // Left-align header + DefaultTableCellRenderer headerRenderer = new DefaultTableCellRenderer(); + headerRenderer.setHorizontalAlignment(SwingConstants.LEFT); + getTableHeader().setDefaultRenderer(headerRenderer); + + // Set row height to match tree row height + if (tree.getRowHeight() < 1) { + setRowHeight(18); + } + } + + /** + * Returns the tree that is being shared between the model. + */ + public JTree getTree() { + return tree; + } + + /** + * Overridden to pass the new rowHeight to the tree. + */ + @Override + public void setRowHeight(int rowHeight) { + super.setRowHeight(rowHeight); + if (tree != null && tree.getRowHeight() != rowHeight) { + tree.setRowHeight(getRowHeight()); + } + } + + /** + * Returns the index of the row that the editing is occurring on. + * Because we have to track the editing row in the JTree separately, + * return -1 if the editing column is not the tree column. + */ + @Override + public int getEditingRow() { + return (getColumnClass(editingColumn) == TreeTableModel.class) ? -1 : editingRow; + } + + /** + * A TreeCellRenderer that displays a JTree. + */ + public class TreeTableCellRenderer extends JTree implements TableCellRenderer { + + protected int visibleRow; + + public TreeTableCellRenderer(TreeModel model) { + super(model); + } + + /** + * updateUI is overridden to set the colors of the Tree's renderer + * to match that of the table. + */ + @Override + public void updateUI() { + super.updateUI(); + // Make the tree's cell renderer use the table's foreground and + // background colors (the original renderer will keep its own). + TreeCellRenderer tcr = getCellRenderer(); + if (tcr instanceof DefaultTreeCellRenderer) { + DefaultTreeCellRenderer dtcr = (DefaultTreeCellRenderer) tcr; + dtcr.setTextSelectionColor(UIManager.getColor("Table.selectionForeground")); + dtcr.setBackgroundSelectionColor(UIManager.getColor("Table.selectionBackground")); + dtcr.setTextNonSelectionColor(UIManager.getColor("Table.foreground")); + dtcr.setBackgroundNonSelectionColor(UIManager.getColor("Table.background")); + } + } + + /** + * Sets the row height of the tree and forwards the row height to + * the table. + */ + @Override + public void setRowHeight(int rowHeight) { + if (rowHeight > 0) { + super.setRowHeight(rowHeight); + if (JTreeTable.this != null && + JTreeTable.this.getRowHeight() != rowHeight) { + JTreeTable.this.setRowHeight(getRowHeight()); + } + } + } + + /** + * This is overridden to set the height to match that of the JTable. + */ + @Override + public void setBounds(int x, int y, int w, int h) { + super.setBounds(x, 0, w, JTreeTable.this.getHeight()); + } + + /** + * Sublcassed to translate the graphics such that the last visible row + * will be drawn at 0,0. + */ + @Override + public void paint(Graphics g) { + g.translate(0, -visibleRow * getRowHeight()); + super.paint(g); + } + + /** + * TreeCellRenderer method. Overridden to update the visible row. + */ + @Override + public Component getTableCellRendererComponent(JTable table, + Object value, boolean isSelected, boolean hasFocus, + int row, int column) { + Color background; + Color foreground; + + if (isSelected) { + background = table.getSelectionBackground(); + foreground = table.getSelectionForeground(); + } else { + background = table.getBackground(); + foreground = table.getForeground(); + } + visibleRow = row; + setBackground(background); + + TreeCellRenderer tcr = getCellRenderer(); + if (tcr instanceof DefaultTreeCellRenderer) { + DefaultTreeCellRenderer dtcr = (DefaultTreeCellRenderer) tcr; + if (isSelected) { + dtcr.setTextSelectionColor(foreground); + dtcr.setBackgroundSelectionColor(background); + } else { + dtcr.setTextNonSelectionColor(foreground); + dtcr.setBackgroundNonSelectionColor(background); + } + } + return this; + } + } + + /** + * TreeTableCellEditor implementation. Component returned is the + * JTree. + */ + public class TreeTableCellEditor extends AbstractCellEditor + implements TableCellEditor { + + @Override + public Component getTableCellEditorComponent(JTable table, + Object value, boolean isSelected, int r, int c) { + return tree; + } + + /** + * Overridden to return false, and if the event is a mouse event + * it is forwarded to the tree. The return value is adjusted to + * return true if the click count >= 3, or the event is null. + */ + @Override + public boolean isCellEditable(EventObject e) { + if (e instanceof MouseEvent) { + MouseEvent me = (MouseEvent) e; + // Re-dispatch to the tree so expand/collapse works. + // Must use the full constructor with 'button' to avoid + // IllegalArgumentException on Java 9+ (getMaskForButton(0)). + MouseEvent newME = new MouseEvent( + tree, + me.getID(), + me.getWhen(), + me.getModifiersEx(), + me.getX() - getCellRect(0, 0, true).x, + me.getY(), + me.getClickCount(), + me.isPopupTrigger(), + me.getButton()); // <-- required on Java 9+ + tree.dispatchEvent(newME); + } + return false; + } + + @Override + public Object getCellEditorValue() { + return null; + } + } + + /** + * ListToTreeSelectionModelWrapper extends DefaultTreeSelectionModel + * to listen for changes in the ListSelectionModel it maintains. Once + * a change in the ListSelectionModel happens, the paths are updated + * in the DefaultTreeSelectionModel. + */ + class ListToTreeSelectionModelWrapper extends DefaultTreeSelectionModel { + + /** Set to true when we are updating the ListSelectionModel. */ + protected boolean updatingListSelectionModel; + + public ListToTreeSelectionModelWrapper() { + super(); + getListSelectionModel().addListSelectionListener( + e -> updateSelectedPathsFromSelectedRows()); + } + + /** + * Returns the list selection model. ListToTreeSelectionModelWrapper + * listens for changes to this model and updates the selected paths + * accordingly. + */ + ListSelectionModel getListSelectionModel() { + return listSelectionModel; + } + + /** + * This is overridden to set updatingListSelectionModel and message + * super. This is the only place DefaultTreeSelectionModel alters the + * ListSelectionModel. + */ + @Override + public void resetRowSelection() { + if (!updatingListSelectionModel) { + updatingListSelectionModel = true; + try { + super.resetRowSelection(); + } finally { + updatingListSelectionModel = false; + } + } + } + + /** + * If updatingListSelectionModel is false, this will reset the + * selected paths from the selected rows in the list selection model. + */ + protected void updateSelectedPathsFromSelectedRows() { + if (!updatingListSelectionModel) { + updatingListSelectionModel = true; + try { + // This is way expensive, but for now it is not + // common for a JTreeTable to be large. + int minIndex = listSelectionModel.getMinSelectionIndex(); + int maxIndex = listSelectionModel.getMaxSelectionIndex(); + + clearSelection(); + if (minIndex != -1 && maxIndex != -1) { + for (int counter = minIndex; counter <= maxIndex; counter++) { + if (listSelectionModel.isSelectedIndex(counter)) { + TreePath selPath = tree.getPathForRow(counter); + if (selPath != null) { + addSelectionPath(selPath); + } + } + } + } + } finally { + updatingListSelectionModel = false; + } + } + } + } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/TreeTableModel.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/TreeTableModel.java new file mode 100644 index 0000000000..db153de4c7 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/TreeTableModel.java @@ -0,0 +1,21 @@ +package capa.ghidra; + +import javax.swing.tree.TreeModel; + +public interface TreeTableModel extends TreeModel { + + /** Marker class used as the column class for the tree column. */ + class TreeTableModelMarker {} + + int getColumnCount(); + + String getColumnName(int column); + + Class getColumnClass(int column); + + Object getValueAt(Object node, int column); + + boolean isCellEditable(Object node, int column); + + void setValueAt(Object aValue, Object node, int column); +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/TreeTableModelAdapter.java b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/TreeTableModelAdapter.java new file mode 100644 index 0000000000..b3271cfe31 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/java/capa/ghidra/TreeTableModelAdapter.java @@ -0,0 +1,112 @@ +package capa.ghidra; + +import javax.swing.JTree; +import javax.swing.event.TreeExpansionEvent; +import javax.swing.event.TreeExpansionListener; +import javax.swing.event.TreeModelEvent; +import javax.swing.event.TreeModelListener; +import javax.swing.table.AbstractTableModel; +import javax.swing.tree.TreePath; + +/** + * Adapts a TreeTableModel to a TableModel so that JTreeTable can use + * a standard JTable underneath while the first column renders as a JTree. + */ +public class TreeTableModelAdapter extends AbstractTableModel { + + private final JTree tree; + private final TreeTableModel treeTableModel; + + public TreeTableModelAdapter(TreeTableModel treeTableModel, JTree tree) { + this.tree = tree; + this.treeTableModel = treeTableModel; + + // Repaint table when tree expands/collapses + tree.addTreeExpansionListener(new TreeExpansionListener() { + @Override + public void treeExpanded(TreeExpansionEvent event) { + fireTableDataChanged(); + } + + @Override + public void treeCollapsed(TreeExpansionEvent event) { + fireTableDataChanged(); + } + }); + + // Forward tree model changes to table + treeTableModel.addTreeModelListener(new TreeModelListener() { + @Override + public void treeNodesChanged(TreeModelEvent e) { + delayedFireTableDataChanged(); + } + + @Override + public void treeNodesInserted(TreeModelEvent e) { + delayedFireTableDataChanged(); + } + + @Override + public void treeNodesRemoved(TreeModelEvent e) { + delayedFireTableDataChanged(); + } + + @Override + public void treeStructureChanged(TreeModelEvent e) { + delayedFireTableDataChanged(); + } + }); + } + + // ------------------------------------------------------------------------- + // TableModel + // ------------------------------------------------------------------------- + + @Override + public int getColumnCount() { + return treeTableModel.getColumnCount(); + } + + @Override + public String getColumnName(int column) { + return treeTableModel.getColumnName(column); + } + + @Override + public Class getColumnClass(int column) { + return treeTableModel.getColumnClass(column); + } + + @Override + public int getRowCount() { + return tree.getRowCount(); + } + + @Override + public Object getValueAt(int row, int column) { + return treeTableModel.getValueAt(nodeForRow(row), column); + } + + @Override + public boolean isCellEditable(int row, int column) { + return treeTableModel.isCellEditable(nodeForRow(row), column); + } + + @Override + public void setValueAt(Object value, int row, int column) { + treeTableModel.setValueAt(value, nodeForRow(row), column); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private Object nodeForRow(int row) { + TreePath treePath = tree.getPathForRow(row); + return treePath == null ? null : treePath.getLastPathComponent(); + } + + private void delayedFireTableDataChanged() { + javax.swing.SwingUtilities.invokeLater(this::fireTableDataChanged); + } +} \ No newline at end of file diff --git a/capa/ghidra/plugin/extension/src/main/resources/images/README.txt b/capa/ghidra/plugin/extension/src/main/resources/images/README.txt new file mode 100644 index 0000000000..f20ae77b73 --- /dev/null +++ b/capa/ghidra/plugin/extension/src/main/resources/images/README.txt @@ -0,0 +1,2 @@ +The "src/resources/images" directory is intended to hold all image/icon files used by +this module.