Merge branch 'master' into pr-6

This commit is contained in:
Jason Tang
2026-04-03 15:40:08 -04:00
13 changed files with 115 additions and 39 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"plugin": {
"name": "IDAssist",
"entryPoint": "idassist_plugin.py",
"version": "1.5.0",
"version": "1.6.0",
"description": "AI-powered reverse engineering plugin with LLM analysis, semantic knowledge graphs, RAG search, and SymGraph collaboration",
"license": "MIT",
"urls": {
+1
View File
@@ -1785,6 +1785,7 @@ Analyze the specific instruction/line of code marked with >>> below. Provide a d
return
self.view.update_security_info(
node.risk_level,
node.category,
node.activity_profile,
node.security_flags,
node.network_apis,
+12 -1
View File
@@ -481,13 +481,24 @@ class SemanticGraphController:
results = []
for edge in edges:
target_id = edge.target_id if edge.source_id == node_id else edge.source_id
target_node = self.graph_store.get_node_by_id(str(target_id))
results.append({
"type": edge.edge_type,
"target": str(target_id),
"target_id": str(target_id),
"target_label": self._format_node_display_name(target_node, str(target_id)),
"weight": edge.weight or 1.0,
})
return results
@staticmethod
def _format_node_display_name(node, node_id: str) -> str:
if node:
if node.name:
return node.name
if node.address is not None:
return f"0x{int(node.address):x}"
return f"{node_id[:8]}..." if len(node_id) > 8 else node_id
def _collect_graph(self, binary_hash: str, center_id: int, n_hops: int, edge_types: Set[str]):
max_nodes = 50
visited = {center_id}
+3
View File
@@ -593,6 +593,7 @@ class ApplySymbolsWorker(QThread):
file_paths=self._coerce_list(props.get("file_paths")),
domains=self._coerce_list(props.get("domains")),
registry_keys=self._coerce_list(props.get("registry_keys")),
category=props.get("category"),
risk_level=props.get("risk_level"),
activity_profile=props.get("activity_profile"),
analysis_depth=int(props.get("analysis_depth", 0) or 0),
@@ -1672,6 +1673,8 @@ class SymGraphController(QObject):
result['domains'] = list(node.domains)
if node.registry_keys:
result['registry_keys'] = list(node.registry_keys)
if node.category:
result['category'] = node.category
if node.risk_level:
result['risk_level'] = node.risk_level
+31 -10
View File
@@ -143,6 +143,9 @@ class AnalysisDBService:
"address": "INTEGER",
"binary_id": "TEXT",
"name": "TEXT",
"signature": "TEXT",
"decompiled_code": "TEXT",
"disassembly": "TEXT",
"raw_content": "TEXT",
"llm_summary": "TEXT",
"confidence": "REAL",
@@ -155,6 +158,7 @@ class AnalysisDBService:
"file_paths": "TEXT",
"domains": "TEXT",
"registry_keys": "TEXT",
"category": "TEXT",
"risk_level": "TEXT",
"activity_profile": "TEXT",
"analysis_depth": "INTEGER",
@@ -192,6 +196,7 @@ class AnalysisDBService:
expected_fts = [
"id",
"name",
"signature",
"llm_summary",
"security_flags",
]
@@ -209,6 +214,9 @@ class AnalysisDBService:
address INTEGER,
binary_id TEXT NOT NULL,
name TEXT,
signature TEXT,
decompiled_code TEXT,
disassembly TEXT,
raw_content TEXT,
llm_summary TEXT,
confidence REAL DEFAULT 0.0,
@@ -221,6 +229,7 @@ class AnalysisDBService:
file_paths TEXT,
domains TEXT,
registry_keys TEXT,
category TEXT,
risk_level TEXT,
activity_profile TEXT,
analysis_depth INTEGER DEFAULT 0,
@@ -306,6 +315,7 @@ class AnalysisDBService:
CREATE VIRTUAL TABLE IF NOT EXISTS node_fts USING fts5(
id,
name,
signature,
llm_summary,
security_flags,
content='graph_nodes',
@@ -315,22 +325,22 @@ class AnalysisDBService:
self._drop_graph_nodes_triggers(cursor)
cursor.execute('''
CREATE TRIGGER IF NOT EXISTS graph_nodes_ai AFTER INSERT ON graph_nodes BEGIN
INSERT INTO node_fts(rowid, id, name, llm_summary, security_flags)
VALUES (new.rowid, new.id, new.name, new.llm_summary, new.security_flags);
INSERT INTO node_fts(rowid, id, name, signature, llm_summary, security_flags)
VALUES (new.rowid, new.id, new.name, new.signature, new.llm_summary, new.security_flags);
END;
''')
cursor.execute('''
CREATE TRIGGER IF NOT EXISTS graph_nodes_ad AFTER DELETE ON graph_nodes BEGIN
INSERT INTO node_fts(node_fts, rowid, id, name, llm_summary, security_flags)
VALUES ('delete', old.rowid, old.id, old.name, old.llm_summary, old.security_flags);
INSERT INTO node_fts(node_fts, rowid, id, name, signature, llm_summary, security_flags)
VALUES ('delete', old.rowid, old.id, old.name, old.signature, old.llm_summary, old.security_flags);
END;
''')
cursor.execute('''
CREATE TRIGGER IF NOT EXISTS graph_nodes_au AFTER UPDATE ON graph_nodes BEGIN
INSERT INTO node_fts(node_fts, rowid, id, name, llm_summary, security_flags)
VALUES ('delete', old.rowid, old.id, old.name, old.llm_summary, old.security_flags);
INSERT INTO node_fts(rowid, id, name, llm_summary, security_flags)
VALUES (new.rowid, new.id, new.name, new.llm_summary, new.security_flags);
INSERT INTO node_fts(node_fts, rowid, id, name, signature, llm_summary, security_flags)
VALUES ('delete', old.rowid, old.id, old.name, old.signature, old.llm_summary, old.security_flags);
INSERT INTO node_fts(rowid, id, name, signature, llm_summary, security_flags)
VALUES (new.rowid, new.id, new.name, new.signature, new.llm_summary, new.security_flags);
END;
''')
except Exception as e:
@@ -447,8 +457,11 @@ class AnalysisDBService:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_line_explanations_lookup ON BNLineExplanations(binary_hash, line_address, view_type)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_line_explanations_function ON BNLineExplanations(binary_hash, function_start)')
# Note: GraphRAG tables (nodes, edges, communities, FTS) are now created
# by database migrations in db_migrations.py (migrations 004-006)
# Reconcile GraphRAG tables against the current expected schema even if
# the stored schema_version is stale or older plugin runs partially
# applied migrations. This prevents runtime read failures on missing
# graph columns such as category/risk_level.
self._ensure_graphrag_schema(cursor)
conn.commit()
log.log_info("AnalysisDB schema created successfully")
@@ -472,6 +485,14 @@ class AnalysisDBService:
try:
from .db_migrations import DatabaseMigrations
DatabaseMigrations.migrate_analysis_db(self._db_path)
with self._db_lock:
conn = self._get_connection()
try:
cursor = conn.cursor()
self._ensure_graphrag_schema(cursor)
conn.commit()
finally:
conn.close()
except Exception as e:
log.log_warn(f"Migration failed: {e}")
+2 -1
View File
@@ -258,7 +258,7 @@ class DatabaseMigrations:
"id", "type", "address", "binary_id", "name", "signature", "decompiled_code", "disassembly", "raw_content",
"llm_summary", "confidence", "embedding", "security_flags",
"network_apis", "file_io_apis", "ip_addresses", "urls",
"file_paths", "domains", "registry_keys", "risk_level",
"file_paths", "domains", "registry_keys", "category", "risk_level",
"activity_profile", "analysis_depth", "created_at", "updated_at",
"is_stale", "user_edited"
}
@@ -299,6 +299,7 @@ class DatabaseMigrations:
file_paths TEXT,
domains TEXT,
registry_keys TEXT,
category TEXT,
risk_level TEXT,
activity_profile TEXT,
analysis_depth INTEGER DEFAULT 0,
+40 -13
View File
@@ -49,6 +49,23 @@ class GraphStore:
except Exception:
return []
@staticmethod
def _normalize_semantic_fields(category: Optional[str], security_flags: Optional[List[str]]) -> Tuple[Optional[str], List[str]]:
normalized_category = category.strip().lower().replace(" ", "_") if isinstance(category, str) and category.strip() else None
filtered_flags: List[str] = []
for flag in security_flags or []:
if not isinstance(flag, str):
continue
trimmed = flag.strip()
if not trimmed or trimmed == "LLM_FLAGGED":
continue
if trimmed.startswith("CATEGORY_"):
if normalized_category is None:
normalized_category = trimmed[len("CATEGORY_"):].lower()
continue
filtered_flags.append(trimmed)
return normalized_category, filtered_flags
@staticmethod
def _now_ms() -> int:
return int(time.time() * 1000)
@@ -61,12 +78,13 @@ class GraphStore:
f"{qualifier}signature, {qualifier}decompiled_code, {qualifier}disassembly, {qualifier}raw_content, "
f"{qualifier}llm_summary, {qualifier}confidence, {qualifier}embedding, {qualifier}security_flags, "
f"{qualifier}network_apis, {qualifier}file_io_apis, {qualifier}ip_addresses, {qualifier}urls, "
f"{qualifier}file_paths, {qualifier}domains, {qualifier}registry_keys, {qualifier}risk_level, "
f"{qualifier}file_paths, {qualifier}domains, {qualifier}registry_keys, {qualifier}category, {qualifier}risk_level, "
f"{qualifier}activity_profile, {qualifier}analysis_depth, {qualifier}created_at, {qualifier}updated_at, "
f"{qualifier}is_stale, {qualifier}user_edited"
)
def _row_to_node(self, row: Tuple[Any, ...]) -> GraphNode:
category, security_flags = self._normalize_semantic_fields(row[20], self._deserialize_list(row[12]))
return GraphNode(
id=row[0],
binary_hash=row[1],
@@ -80,7 +98,7 @@ class GraphStore:
llm_summary=row[9],
confidence=row[10] if row[10] is not None else 0.0,
embedding=row[11],
security_flags=self._deserialize_list(row[12]),
security_flags=security_flags,
network_apis=self._deserialize_list(row[13]),
file_io_apis=self._deserialize_list(row[14]),
ip_addresses=self._deserialize_list(row[15]),
@@ -88,13 +106,14 @@ class GraphStore:
file_paths=self._deserialize_list(row[17]),
domains=self._deserialize_list(row[18]),
registry_keys=self._deserialize_list(row[19]),
risk_level=row[20],
activity_profile=row[21],
analysis_depth=row[22] if row[22] is not None else 0,
created_at=row[23],
updated_at=row[24],
is_stale=bool(row[25]),
user_edited=bool(row[26]),
category=category,
risk_level=row[21],
activity_profile=row[22],
analysis_depth=row[23] if row[23] is not None else 0,
created_at=row[24],
updated_at=row[25],
is_stale=bool(row[26]),
user_edited=bool(row[27]),
)
def get_node_by_address(self, binary_hash: str, node_type: str, address: int) -> Optional[GraphNode]:
@@ -151,6 +170,8 @@ class GraphStore:
existing = self.get_node_by_address(node.binary_hash, node.node_type, node.address)
node.id = existing.id if existing else str(uuid.uuid4())
node.category, node.security_flags = self._normalize_semantic_fields(node.category, node.security_flags)
now_ms = self._now_ms()
security_flags = self._serialize_list(node.security_flags)
network_apis = self._serialize_list(node.network_apis)
@@ -169,10 +190,10 @@ class GraphStore:
INSERT INTO graph_nodes (
id, type, address, binary_id, name, signature, decompiled_code, disassembly, raw_content, llm_summary,
confidence, embedding, security_flags, network_apis, file_io_apis,
ip_addresses, urls, file_paths, domains, registry_keys, risk_level,
ip_addresses, urls, file_paths, domains, registry_keys, category, risk_level,
activity_profile, analysis_depth, created_at, updated_at, is_stale,
user_edited
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
-- STRUCTURAL DATA: Always update from fresh extraction
type = excluded.type,
@@ -191,6 +212,7 @@ class GraphStore:
file_paths = excluded.file_paths,
domains = excluded.domains,
registry_keys = excluded.registry_keys,
category = excluded.category,
risk_level = excluded.risk_level,
activity_profile = excluded.activity_profile,
updated_at = excluded.updated_at,
@@ -223,6 +245,7 @@ class GraphStore:
file_paths,
domains,
registry_keys,
node.category,
node.risk_level,
node.activity_profile,
node.analysis_depth,
@@ -609,6 +632,8 @@ class GraphStore:
# Cache the node ID
self._node_cache[cache_key] = node.id
node.category, node.security_flags = self._normalize_semantic_fields(node.category, node.security_flags)
# Always queue for batch insert/update (even existing nodes need is_stale updated)
with self._batch_lock:
self._node_batch.append(node)
@@ -685,6 +710,7 @@ class GraphStore:
self._serialize_list(node.file_paths),
self._serialize_list(node.domains),
self._serialize_list(node.registry_keys),
node.category,
node.risk_level,
node.activity_profile,
node.analysis_depth,
@@ -702,10 +728,10 @@ class GraphStore:
INSERT INTO graph_nodes (
id, type, address, binary_id, name, signature, decompiled_code, disassembly, raw_content, llm_summary,
confidence, embedding, security_flags, network_apis, file_io_apis,
ip_addresses, urls, file_paths, domains, registry_keys, risk_level,
ip_addresses, urls, file_paths, domains, registry_keys, category, risk_level,
activity_profile, analysis_depth, created_at, updated_at, is_stale,
user_edited
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
-- STRUCTURAL DATA: Always update from fresh extraction
type = excluded.type,
@@ -724,6 +750,7 @@ class GraphStore:
file_paths = excluded.file_paths,
domains = excluded.domains,
registry_keys = excluded.registry_keys,
category = excluded.category,
risk_level = excluded.risk_level,
activity_profile = excluded.activity_profile,
updated_at = excluded.updated_at,
+1
View File
@@ -214,6 +214,7 @@ class GraphNode:
file_paths: List[str] = field(default_factory=list)
domains: List[str] = field(default_factory=list)
registry_keys: List[str] = field(default_factory=list)
category: Optional[str] = None
activity_profile: Optional[str] = None
risk_level: Optional[str] = None
analysis_depth: int = 0
+1 -1
View File
@@ -41,7 +41,7 @@ class GraphRAGQueryEngine:
"has_structure_data": has_structure,
"summary": summary,
"security_flags": node.security_flags or [],
"category": self._extract_category(node.llm_summary),
"category": node.category or self._extract_category(node.llm_summary),
"confidence": 0.8 if has_semantic else 0.0,
"callers": callers,
"callees": callees,
@@ -128,7 +128,10 @@ class SemanticExtractor:
if not response:
return False
from .extraction_prompts import extract_category
node.llm_summary = response.strip()
node.category = extract_category(response) or None
node.confidence = 0.85 # LLM-generated summary confidence
node.is_stale = False
node.user_edited = False
+4
View File
@@ -283,6 +283,8 @@ class GraphNode:
properties['domains'] = data.get('domains', [])
if 'registry_keys' in data and 'registry_keys' not in properties:
properties['registry_keys'] = data.get('registry_keys', [])
if 'category' in data and 'category' not in properties:
properties['category'] = data.get('category')
if 'risk_level' in data and 'risk_level' not in properties:
properties['risk_level'] = data.get('risk_level')
if 'activity_profile' in data and 'activity_profile' not in properties:
@@ -320,6 +322,8 @@ class GraphNode:
result['disassembly'] = self.properties.get('disassembly')
if self.properties.get('decompiled_code') or self.properties.get('raw_content'):
result['raw_content'] = self.properties.get('decompiled_code') or self.properties.get('raw_content')
if self.properties.get('category'):
result['category'] = self.properties.get('category')
if self.properties:
result['properties'] = self.properties
return result
+14 -10
View File
@@ -233,6 +233,7 @@ class ExplainTabView(QWidget):
grid.setVerticalSpacing(2)
self.risk_label = QLabel("Risk: —")
self.category_label = QLabel("Category: —")
self.activity_label = QLabel("Activity: —")
self.flags_label = QLabel("Flags: None")
self.flags_label.setWordWrap(True)
@@ -252,17 +253,18 @@ class ExplainTabView(QWidget):
self.file_text.setLineWrapMode(QTextEdit.NoWrap)
grid.addWidget(self.risk_label, 0, 0)
grid.addWidget(self.activity_label, 0, 1)
grid.addWidget(self.flags_label, 1, 0, 1, 2)
grid.addWidget(self.network_label, 2, 0)
grid.addWidget(self.file_label, 2, 1)
grid.addWidget(self.network_text, 3, 0)
grid.addWidget(self.file_text, 3, 1)
grid.addWidget(self.category_label, 0, 1)
grid.addWidget(self.activity_label, 1, 0, 1, 2)
grid.addWidget(self.flags_label, 2, 0, 1, 2)
grid.addWidget(self.network_label, 3, 0)
grid.addWidget(self.file_label, 3, 1)
grid.addWidget(self.network_text, 4, 0)
grid.addWidget(self.file_text, 4, 1)
# Let the API text areas absorb extra vertical space from the splitter
for r in range(0, 3):
for r in range(0, 4):
grid.setRowStretch(r, 0)
grid.setRowStretch(3, 1)
grid.setRowStretch(4, 1)
grid.setColumnStretch(0, 1)
grid.setColumnStretch(1, 1)
@@ -356,9 +358,10 @@ class ExplainTabView(QWidget):
else:
self.explain_editor.setPlainText(markdown_text)
def update_security_info(self, risk_level, activity_profile, security_flags, network_apis, file_io_apis):
def update_security_info(self, risk_level, category, activity_profile, security_flags, network_apis, file_io_apis):
"""Update the security analysis panel."""
self.risk_label.setText(f"Risk: {risk_level or '—'}")
self.category_label.setText(f"Category: {category or '—'}")
self.activity_label.setText(f"Activity: {activity_profile or '—'}")
flags_text = ", ".join(security_flags) if security_flags else "None"
@@ -367,12 +370,13 @@ class ExplainTabView(QWidget):
self.network_text.setPlainText("\n".join(network_apis) if network_apis else "(none detected)")
self.file_text.setPlainText("\n".join(file_io_apis) if file_io_apis else "(none detected)")
has_data = bool(risk_level or activity_profile or security_flags or network_apis or file_io_apis)
has_data = bool(risk_level or category or activity_profile or security_flags or network_apis or file_io_apis)
self.security_group.setVisible(has_data)
def clear_security_info(self):
"""Clear and hide the security analysis panel."""
self.risk_label.setText("Risk: —")
self.category_label.setText("Category: —")
self.activity_label.setText("Activity: —")
self.flags_label.setText("Flags: None")
self.network_text.clear()
+2 -2
View File
@@ -432,10 +432,10 @@ class SemanticGraphListView(QWidget):
row = self.edges_table.rowCount()
self.edges_table.insertRow(row)
self.edges_table.setItem(row, 0, QTableWidgetItem(edge["type"]))
self.edges_table.setItem(row, 1, QTableWidgetItem(edge["target"]))
self.edges_table.setItem(row, 1, QTableWidgetItem(edge["target_label"]))
self.edges_table.setItem(row, 2, QTableWidgetItem(f"{edge.get('weight', 1.0):.2f}"))
button = QPushButton("View")
button.clicked.connect(lambda _=None, target=edge["target"]: self.edge_clicked.emit(target))
button.clicked.connect(lambda _=None, target_id=edge["target_id"]: self.edge_clicked.emit(target_id))
self.edges_table.setCellWidget(row, 3, button)
def set_security_flags(self, flags: List[str]):