mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* security: sanitize Cypher labels/relationship types/property keys (GHSA-482h-hw99-h62p) Node labels and property keys passed to create_node/create_relationship were interpolated directly into Cypher strings in the Neptune, Neo4j, and FalkorDB graph stores. Property values are parameterized, but labels and keys can't be bound as parameters, and nothing validated them, so a document-derived entity type or property name could close the current Cypher token early and append arbitrary statements (e.g. DETACH DELETE), running with the application's database credentials. - New shared semantica/graph_store/query_sanitize.py: sanitize_identifier() generalizes age_store.py's existing _sanitize_label/_sanitize_rel_type (the only backend that already validated this) into a helper the other backends can import without an import cycle with graph_store.py/methods.py. - Applied at every label/relationship-type/property-key interpolation site in amazon_neptune.py, neo4j_store.py, falkordb_store.py, graph_store.py (degree_centrality's own query builder), and methods.py (update_relationship's own query builder) — create_node, create_nodes, create_relationship, get_nodes, get_relationships, get_neighbors, shortest_path, update_node, create_index, and all relationship-type filters. - depth/max_depth path-length parameters are also cast to int before interpolation as defense-in-depth (they're already typed int, but Python doesn't enforce that at runtime). Added tests/graph_store/test_cypher_injection.py (12 tests covering the sanitizer directly and reproducing the advisory's injection payload against Neptune/Neo4j/FalkorDB create_node/create_relationship — asserts the malicious query is never built or sent), plus regression tests for graph_store.py's degree_centrality and methods.py's update_relationship. Full graph_store test suite (224 tests) passes with no regressions. * fix(graph-store): prevent depth-based Cypher injection * test(graph-store): tighten injection regression assertions * docs(changelog): add PR #910 (GHSA-482h Cypher injection) entry --------- Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
252 lines
10 KiB
Python
252 lines
10 KiB
Python
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
from typing import Any, Dict, List, Optional
|
|
from semantica.graph_store.graph_store import GraphStore
|
|
|
|
class MockGraphStore:
|
|
def __init__(self, **config):
|
|
self.config = config
|
|
self.nodes = {}
|
|
self.relationships = {}
|
|
self.node_counter = 0
|
|
self.rel_counter = 0
|
|
self.connected = False
|
|
|
|
def connect(self, **options):
|
|
self.connected = True
|
|
return True
|
|
|
|
def close(self):
|
|
self.connected = False
|
|
|
|
def create_node(self, labels: List[str], properties: Dict[str, Any], **options) -> Dict[str, Any]:
|
|
self.node_counter += 1
|
|
node_id = self.node_counter
|
|
node = {
|
|
"id": node_id,
|
|
"labels": labels,
|
|
"properties": properties
|
|
}
|
|
self.nodes[node_id] = node
|
|
return node
|
|
|
|
def create_nodes(self, nodes: List[Dict[str, Any]], **options) -> List[Dict[str, Any]]:
|
|
created = []
|
|
for node_data in nodes:
|
|
created.append(self.create_node(node_data.get("labels", []), node_data.get("properties", {})))
|
|
return created
|
|
|
|
def get_node(self, node_id: int, **options) -> Optional[Dict[str, Any]]:
|
|
return self.nodes.get(node_id)
|
|
|
|
def get_nodes(self, labels: Optional[List[str]] = None, properties: Optional[Dict[str, Any]] = None, limit: int = 100, **options) -> List[Dict[str, Any]]:
|
|
result = []
|
|
for node in self.nodes.values():
|
|
if labels:
|
|
if not any(label in node["labels"] for label in labels):
|
|
continue
|
|
if properties:
|
|
match = True
|
|
for k, v in properties.items():
|
|
if node["properties"].get(k) != v:
|
|
match = False
|
|
break
|
|
if not match:
|
|
continue
|
|
result.append(node)
|
|
if len(result) >= limit:
|
|
break
|
|
return result
|
|
|
|
def update_node(self, node_id: int, properties: Dict[str, Any], merge: bool = True, **options) -> Dict[str, Any]:
|
|
if node_id not in self.nodes:
|
|
raise Exception(f"Node {node_id} not found")
|
|
|
|
if merge:
|
|
self.nodes[node_id]["properties"].update(properties)
|
|
else:
|
|
self.nodes[node_id]["properties"] = properties
|
|
return self.nodes[node_id]
|
|
|
|
def delete_node(self, node_id: int, detach: bool = True, **options) -> bool:
|
|
if node_id in self.nodes:
|
|
del self.nodes[node_id]
|
|
# Handle detach (delete relationships) if needed
|
|
if detach:
|
|
to_delete = []
|
|
for rel_id, rel in self.relationships.items():
|
|
if rel["start_node_id"] == node_id or rel["end_node_id"] == node_id:
|
|
to_delete.append(rel_id)
|
|
for rel_id in to_delete:
|
|
del self.relationships[rel_id]
|
|
return True
|
|
return False
|
|
|
|
def create_relationship(self, start_node_id: int, end_node_id: int, rel_type: str, properties: Optional[Dict[str, Any]] = None, **options) -> Dict[str, Any]:
|
|
if start_node_id not in self.nodes or end_node_id not in self.nodes:
|
|
raise Exception("Nodes not found")
|
|
|
|
self.rel_counter += 1
|
|
rel_id = self.rel_counter
|
|
rel = {
|
|
"id": rel_id,
|
|
"start_node_id": start_node_id,
|
|
"end_node_id": end_node_id,
|
|
"type": rel_type,
|
|
"properties": properties or {}
|
|
}
|
|
self.relationships[rel_id] = rel
|
|
return rel
|
|
|
|
def get_relationships(self, node_id: Optional[int] = None, rel_type: Optional[str] = None, direction: str = "both", limit: int = 100, **options) -> List[Dict[str, Any]]:
|
|
result = []
|
|
for rel in self.relationships.values():
|
|
if node_id is not None:
|
|
if direction == "out" and rel["start_node_id"] != node_id:
|
|
continue
|
|
elif direction == "in" and rel["end_node_id"] != node_id:
|
|
continue
|
|
elif direction == "both" and rel["start_node_id"] != node_id and rel["end_node_id"] != node_id:
|
|
continue
|
|
|
|
if rel_type and rel["type"] != rel_type:
|
|
continue
|
|
|
|
result.append(rel)
|
|
if len(result) >= limit:
|
|
break
|
|
return result
|
|
|
|
def delete_relationship(self, rel_id: int, **options) -> bool:
|
|
if rel_id in self.relationships:
|
|
del self.relationships[rel_id]
|
|
return True
|
|
return False
|
|
|
|
def execute_query(self, query: str, parameters: Optional[Dict[str, Any]] = None, **options) -> Dict[str, Any]:
|
|
return {"records": [], "summary": "Mock query executed"}
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
return {"nodes": len(self.nodes), "relationships": len(self.relationships)}
|
|
|
|
def create_index(self, label: str, property_name: str, index_type: str = "btree", **options) -> bool:
|
|
return True
|
|
|
|
def shortest_path(self, start_node_id: int, end_node_id: int, rel_type: Optional[str] = None, max_depth: int = 10, **options) -> Optional[Dict[str, Any]]:
|
|
return None # Simplified
|
|
|
|
def get_neighbors(self, node_id: int, rel_type: Optional[str] = None, direction: str = "both", depth: int = 1, **options) -> List[Dict[str, Any]]:
|
|
return [] # Simplified
|
|
|
|
class TestGraphStore(unittest.TestCase):
|
|
def setUp(self):
|
|
# Patch Neo4jStore to return our MockGraphStore
|
|
self.patcher = patch('semantica.graph_store.neo4j_store.Neo4jStore', side_effect=MockGraphStore)
|
|
self.mock_store_class = self.patcher.start()
|
|
|
|
# Initialize GraphStore with 'neo4j' backend (which will use our mock)
|
|
self.store = GraphStore(backend="neo4j")
|
|
self.store.connect()
|
|
|
|
def tearDown(self):
|
|
self.store.close()
|
|
self.patcher.stop()
|
|
|
|
def test_node_operations(self):
|
|
# Create
|
|
node = self.store.create_node(labels=["Person"], properties={"name": "Alice", "age": 30})
|
|
self.assertIsNotNone(node)
|
|
self.assertEqual(node["properties"]["name"], "Alice")
|
|
node_id = node["id"]
|
|
|
|
# Get
|
|
fetched_node = self.store.get_node(node_id)
|
|
self.assertEqual(fetched_node["id"], node_id)
|
|
self.assertEqual(fetched_node["properties"]["name"], "Alice")
|
|
|
|
# Get with filters
|
|
nodes = self.store.get_nodes(labels=["Person"], properties={"name": "Alice"})
|
|
self.assertEqual(len(nodes), 1)
|
|
self.assertEqual(nodes[0]["id"], node_id)
|
|
|
|
# Update
|
|
updated_node = self.store.update_node(node_id, properties={"age": 31})
|
|
self.assertEqual(updated_node["properties"]["age"], 31)
|
|
self.assertEqual(updated_node["properties"]["name"], "Alice") # Merge behavior
|
|
|
|
# Delete
|
|
result = self.store.delete_node(node_id)
|
|
self.assertTrue(result)
|
|
self.assertIsNone(self.store.get_node(node_id))
|
|
|
|
def test_relationship_operations(self):
|
|
node1 = self.store.create_node(["Person"], {"name": "Alice"})
|
|
node2 = self.store.create_node(["Person"], {"name": "Bob"})
|
|
|
|
# Create
|
|
rel = self.store.create_relationship(node1["id"], node2["id"], "KNOWS", {"since": 2023})
|
|
self.assertIsNotNone(rel)
|
|
self.assertEqual(rel["type"], "KNOWS")
|
|
rel_id = rel["id"]
|
|
|
|
# Get
|
|
rels = self.store.get_relationships(node_id=node1["id"], direction="out")
|
|
self.assertEqual(len(rels), 1)
|
|
self.assertEqual(rels[0]["id"], rel_id)
|
|
|
|
# Delete
|
|
result = self.store.delete_relationship(rel_id)
|
|
self.assertTrue(result)
|
|
rels = self.store.get_relationships(node_id=node1["id"])
|
|
self.assertEqual(len(rels), 0)
|
|
|
|
def test_batch_node_creation(self):
|
|
nodes_data = [
|
|
{"labels": ["Person"], "properties": {"name": "User1"}},
|
|
{"labels": ["Person"], "properties": {"name": "User2"}}
|
|
]
|
|
created_nodes = self.store.create_nodes(nodes_data)
|
|
self.assertEqual(len(created_nodes), 2)
|
|
self.assertEqual(created_nodes[0]["properties"]["name"], "User1")
|
|
self.assertEqual(created_nodes[1]["properties"]["name"], "User2")
|
|
|
|
def test_query_execution(self):
|
|
# Since MockGraphStore returns a fixed response
|
|
result = self.store.execute_query("MATCH (n) RETURN n")
|
|
self.assertEqual(result["summary"], "Mock query executed")
|
|
|
|
def test_degree_centrality_rejects_malicious_label(self):
|
|
"""Regression test for GHSA-482h-hw99-h62p: degree_centrality()
|
|
interpolates labels/rel_type directly into a Cypher MATCH clause
|
|
(graph_store.py's own query builder, not delegated to the backend),
|
|
so an unvalidated label was a direct injection point."""
|
|
from semantica.utils.exceptions import ValidationError
|
|
evil_label = "N}) MATCH (victim) DETACH DELETE victim //"
|
|
with self.assertRaises(ValidationError):
|
|
self.store._manager.analytics.degree_centrality(labels=[evil_label])
|
|
|
|
def test_degree_centrality_rejects_malicious_rel_type(self):
|
|
from semantica.utils.exceptions import ValidationError
|
|
evil_rel_type = "R]-() DETACH DELETE n //"
|
|
with self.assertRaises(ValidationError):
|
|
self.store._manager.analytics.degree_centrality(rel_type=evil_rel_type)
|
|
|
|
def test_degree_centrality_with_legitimate_input_still_works(self):
|
|
result = self.store._manager.analytics.degree_centrality(labels=["Person"])
|
|
self.assertEqual(result, []) # MockGraphStore.execute_query returns no records
|
|
|
|
class TestGraphStoreInitialization(unittest.TestCase):
|
|
def test_falkordb_initialization(self):
|
|
with patch('semantica.graph_store.falkordb_store.FalkorDBStore', side_effect=MockGraphStore) as mock_falkor:
|
|
store = GraphStore(backend="falkordb")
|
|
self.assertIsInstance(store._store_backend, MockGraphStore)
|
|
mock_falkor.assert_called_once()
|
|
|
|
def test_invalid_backend(self):
|
|
from semantica.utils.exceptions import ValidationError
|
|
with self.assertRaises(ValidationError):
|
|
GraphStore(backend="invalid_backend")
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|