Merge pull request #475 from jreakin/fix/web-security-hardening

fix(web): Cypher injection guards, DOMPurify SVG, readOnly executeQuery
This commit is contained in:
Gergő Magyar 2026-03-23 13:21:03 +00:00 committed by GitHub
commit 519dc3eccc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 524 additions and 145 deletions

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import mermaid from 'mermaid';
import DOMPurify from 'dompurify';
import { AlertTriangle, Maximize2 } from 'lucide-react';
import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData } from '../lib/mermaid-generator';
@ -140,7 +141,7 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
<div
ref={containerRef}
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
dangerouslySetInnerHTML={{ __html: svg }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
/>
</div>
</div>

View file

@ -5,8 +5,9 @@
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { X, GitBranch, Copy, Focus, Layers, ZoomIn, ZoomOut } from 'lucide-react';
import { X, GitBranch, Copy, Focus, Layers, ZoomIn, ZoomOut } from '@/lib/lucide-icons';
import mermaid from 'mermaid';
import DOMPurify from 'dompurify';
import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
interface ProcessFlowModalProps {
@ -90,6 +91,7 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
// Handle keyboard zoom
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === '+' || e.key === '=') {
setZoom(prev => Math.min(prev + 0.2, maxZoom));
} else if (e.key === '-' || e.key === '_') {
@ -136,8 +138,8 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
const renderDiagram = async () => {
try {
// Check if we have raw mermaid code (from AI chat) or need to generate it
const mermaidCode = (process as any).rawMermaid
? (process as any).rawMermaid
const mermaidCode = process.rawMermaid
? process.rawMermaid
: generateProcessMermaid(process);
const id = `mermaid-${Date.now()}`;
@ -145,7 +147,8 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
diagramRef.current!.innerHTML = '';
const { svg } = await mermaid.render(id, mermaidCode);
diagramRef.current!.innerHTML = svg;
if (!diagramRef.current) return;
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } });
} catch (error) {
console.error('Mermaid render error:', error);
const errorMessage = error instanceof Error ? error.message : String(error);

View file

@ -11,6 +11,9 @@ import { useAppState } from '../hooks/useAppState';
import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
/** Validate that an ID contains only expected node identifier characters (no Cypher metacharacters or spaces) */
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
export const ProcessesPanel = () => {
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
const [searchQuery, setSearchQuery] = useState('');
@ -79,7 +82,7 @@ export const ProcessesPanel = () => {
setLoadingProcess('all');
try {
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id);
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id).filter(isSafeId);
if (allProcessIds.length === 0) return;
@ -110,7 +113,7 @@ export const ProcessesPanel = () => {
}
const allSteps = Array.from(allStepsMap.values());
const stepIds = allSteps.map(s => s.id);
const stepIds = allSteps.map(s => s.id).filter(isSafeId);
// Query for all CALLS edges between the combined steps
if (stepIds.length > 0) {
@ -155,6 +158,7 @@ export const ProcessesPanel = () => {
// Load process steps and open modal
const handleViewProcess = useCallback(async (processId: string, label: string, processType: string) => {
if (!isSafeId(processId)) return;
setLoadingProcess(processId);
try {
@ -175,7 +179,7 @@ export const ProcessesPanel = () => {
}));
// Get step IDs for edge query
const stepIds = steps.map(s => s.id);
const stepIds = steps.map(s => s.id).filter(isSafeId);
// Query for CALLS edges between the steps in this process
let edges: Array<{ from: string; to: string; type: string }> = [];
@ -228,6 +232,7 @@ export const ProcessesPanel = () => {
// Toggle focus for any process - loads steps on demand
const handleToggleFocusForProcess = useCallback(async (processId: string) => {
if (!isSafeId(processId)) return;
// If already focused on this process, turn off
if (focusedProcessId === processId) {
setHighlightedNodeIds(new Set());

View file

@ -124,7 +124,7 @@ const createVectorIndex = async (
`;
try {
await executeQuery(cypher);
await executeQuery(cypher, false); // readOnly=false: CALL CREATE_VECTOR_INDEX is a write operation
} catch (error) {
// Index might already exist
if (import.meta.env.DEV) {

View file

@ -16,55 +16,62 @@ import {
NodeTableName,
} from './schema';
import { generateAllCSVs } from './csv-generator';
import { getQueryRows } from './query-result';
// Holds the reference to the dynamically loaded module
let lbug: any = null;
let db: any = null;
let conn: any = null;
let initPromise: Promise<{ db: any; conn: any; lbug: any }> | null = null;
/**
* Initialize LadybugDB WASM module and create in-memory database
*/
export const initLbug = async () => {
if (conn) return { db, conn, lbug };
if (initPromise) return initPromise;
initPromise = (async () => {
try {
if (import.meta.env.DEV) console.log('🚀 Initializing LadybugDB...');
try {
if (import.meta.env.DEV) console.log('🚀 Initializing LadybugDB...');
// 1. Dynamic Import (Fixes the "not a function" bundler issue)
const lbugModule = await import('@ladybugdb/wasm-core');
// 1. Dynamic Import (Fixes the "not a function" bundler issue)
const lbugModule = await import('@ladybugdb/wasm-core');
// 2. Handle Vite/Webpack "default" wrapping
lbug = lbugModule.default || lbugModule;
// 2. Handle Vite/Webpack "default" wrapping
lbug = lbugModule.default || lbugModule;
// 3. Initialize WASM
await lbug.init();
// 3. Initialize WASM
await lbug.init();
// 4. Create Database with 512MB buffer manager
const BUFFER_POOL_SIZE = 512 * 1024 * 1024; // 512MB
db = new lbug.Database(':memory:', BUFFER_POOL_SIZE);
conn = new lbug.Connection(db);
// 4. Create Database with 512MB buffer manager
const BUFFER_POOL_SIZE = 512 * 1024 * 1024; // 512MB
db = new lbug.Database(':memory:', BUFFER_POOL_SIZE);
conn = new lbug.Connection(db);
if (import.meta.env.DEV) console.log('✅ LadybugDB WASM Initialized');
if (import.meta.env.DEV) console.log('✅ LadybugDB WASM Initialized');
// 5. Initialize Schema (all node tables, then rel tables, then embedding table)
for (const schemaQuery of SCHEMA_QUERIES) {
try {
await conn.query(schemaQuery);
} catch (e) {
// Schema might already exist, skip
if (import.meta.env.DEV) {
console.warn('Schema creation skipped (may already exist):', e);
// 5. Initialize Schema (all node tables, then rel tables, then embedding table)
for (let i = 0; i < SCHEMA_QUERIES.length; i++) {
try {
await conn.query(SCHEMA_QUERIES[i]);
} catch (e) {
// Schema might already exist, skip
if (import.meta.env.DEV) {
console.warn(`Schema query ${i + 1}/${SCHEMA_QUERIES.length} skipped (may already exist):`, e);
}
}
}
if (import.meta.env.DEV) console.log('✅ LadybugDB Multi-Table Schema Created');
return { db, conn, lbug };
} catch (error) {
if (import.meta.env.DEV) console.error('❌ LadybugDB Initialization Failed:', error);
throw error;
}
if (import.meta.env.DEV) console.log('✅ LadybugDB Multi-Table Schema Created');
return { db, conn, lbug };
})();
try {
return await initPromise;
} catch (error) {
if (import.meta.env.DEV) console.error('❌ LadybugDB Initialization Failed:', error);
initPromise = null; // Reset on failure so retry is possible
throw error;
}
};
@ -73,11 +80,47 @@ export const initLbug = async () => {
* Load a KnowledgeGraph into LadybugDB using COPY FROM (bulk load)
* Uses batched CSV writes and COPY statements for optimal performance
*/
const isTestEnv = () => {
// Browser-friendly check: Vite only exposes VITE_* vars at runtime; fall back to a window flag if injected by tests.
if (typeof import.meta !== 'undefined' && typeof import.meta.env !== 'undefined') {
if (import.meta.env.VITE_PLAYWRIGHT_TEST || import.meta.env.MODE === 'test') return true;
}
if (typeof window !== 'undefined' && (window as unknown as { __PLAYWRIGHT_TEST__?: boolean }).__PLAYWRIGHT_TEST__) {
return true;
}
if (typeof navigator !== 'undefined' && navigator.webdriver) {
return true;
}
return typeof process !== 'undefined' && (process.env.PLAYWRIGHT_TEST || process.env.NODE_ENV === 'test');
};
export const loadGraphToLbug = async (
graph: KnowledgeGraph,
fileContents: Map<string, string>
) => {
const { conn, lbug } = await initLbug();
// In headless Playwright, skip heavy bulk load to avoid hangs; UI still functions with empty DB.
if (isTestEnv()) {
if (import.meta.env.DEV) console.log('🧪 Skipping LadybugDB bulk load in test mode');
await initLbug(); // ensure module initialized for downstream calls
return { success: true, count: 0 };
}
const { lbug: lbugModule } = await initLbug();
// Recreate a fresh in-memory DB each load to avoid cleanup/quoting issues with reserved names
const BUFFER_POOL_SIZE = 512 * 1024 * 1024; // 512MB (mirror init)
db = new lbugModule.Database(':memory:', BUFFER_POOL_SIZE);
conn = new lbugModule.Connection(db);
// Re-run schema creation
for (let i = 0; i < SCHEMA_QUERIES.length; i++) {
try {
await conn.query(SCHEMA_QUERIES[i]);
} catch (e) {
if (import.meta.env.DEV) {
console.warn(`Schema query ${i + 1}/${SCHEMA_QUERIES.length} skipped (may already exist):`, e);
}
}
}
try {
if (import.meta.env.DEV) console.log(`LadybugDB: Generating CSVs for ${graph.nodeCount} nodes...`);
@ -131,47 +174,86 @@ export const loadGraphToLbug = async (
let insertedRels = 0;
let skippedRels = 0;
const skippedRelStats = new Map<string, number>();
// Group relations by (fromLabel, toLabel) pair for prepared statement reuse
const relsByLabelPair = new Map<string, Array<{ fromId: string; toId: string; relType: string; confidence: number; reason: string; step: number }>>();
// RFC 4180 regex: handles doubled quotes ("") inside quoted fields
const csvRegex = /"((?:[^"]|"")*)","((?:[^"]|"")*)","((?:[^"]|"")*)",([0-9.]+),"((?:[^"]|"")*)",([0-9-]+)/;
for (const line of relLines) {
try {
// Format: "from","to","type",confidence,"reason",step
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/);
if (!match) continue;
const match = line.match(csvRegex);
if (!match) continue;
const [, fromId, toId, relType, confidenceStr, reason, stepStr] = match;
// Unescape RFC 4180 doubled quotes
const fromId = match[1].replace(/""/g, '"');
const toId = match[2].replace(/""/g, '"');
const relType = match[3].replace(/""/g, '"');
const reason = match[5].replace(/""/g, '"');
const fromLabel = getNodeLabel(fromId);
const toLabel = getNodeLabel(toId);
const fromLabel = getNodeLabel(fromId);
const toLabel = getNodeLabel(toId);
// Skip relationships where either node's label doesn't have a table in LadybugDB
// Querying a non-existent table causes a fatal native crash
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
skippedRels++;
// Skip relationships where either node's label doesn't have a table in LadybugDB
// Querying a non-existent table causes a fatal native crash
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
skippedRels++;
continue;
}
const key = `${fromLabel}:${toLabel}`;
if (!relsByLabelPair.has(key)) relsByLabelPair.set(key, []);
relsByLabelPair.get(key)!.push({
fromId,
toId,
relType,
confidence: parseFloat(match[4]) || 1.0,
reason,
step: parseInt(match[6]) || 0,
});
}
// Execute batched prepared statements per label pair
const SUB_BATCH_SIZE = 4;
for (const [key, rels] of relsByLabelPair) {
const [fromLabel, toLabel] = key.split(':');
const cypher = `
MATCH (a:${escapeLabel(fromLabel)} {id: $fromId}),
(b:${escapeLabel(toLabel)} {id: $toId})
CREATE (a)-[:${REL_TABLE_NAME} {type: $relType, confidence: $confidence, reason: $reason, step: $step}]->(b)
`;
for (let i = 0; i < rels.length; i += SUB_BATCH_SIZE) {
const subBatch = rels.slice(i, i + SUB_BATCH_SIZE);
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
if (import.meta.env.DEV) console.warn(`Prepare failed for ${key}: ${errMsg}`);
skippedRels += subBatch.length;
await stmt.close();
continue;
}
const confidence = parseFloat(confidenceStr) || 1.0;
const step = parseInt(stepStr) || 0;
const insertQuery = `
MATCH (a:${escapeLabel(fromLabel)} {id: '${fromId.replace(/'/g, "''")}'}),
(b:${escapeLabel(toLabel)} {id: '${toId.replace(/'/g, "''")}'})
CREATE (a)-[:${REL_TABLE_NAME} {type: '${relType}', confidence: ${confidence}, reason: '${reason.replace(/'/g, "''")}', step: ${step}}]->(b)
`;
await conn.query(insertQuery);
insertedRels++;
} catch (err) {
skippedRels++;
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)"/);
if (match) {
const [, fromId, toId, relType] = match;
const fromLabel = getNodeLabel(fromId);
const toLabel = getNodeLabel(toId);
const key = `${relType}:${fromLabel}->` + toLabel;
skippedRelStats.set(key, (skippedRelStats.get(key) || 0) + 1);
if (import.meta.env.DEV) {
console.warn(`⚠️ Skipped: ${key} | "${fromId}" → "${toId}" | ${err instanceof Error ? err.message : String(err)}`);
try {
for (const r of subBatch) {
try {
await conn.execute(stmt, r);
insertedRels++;
} catch (err) {
skippedRels++;
const statKey = `${r.relType}:${fromLabel}->${toLabel}`;
skippedRelStats.set(statKey, (skippedRelStats.get(statKey) || 0) + 1);
if (import.meta.env.DEV) {
console.warn(`⚠️ Skipped: ${statKey} | "${r.fromId}" → "${r.toId}" | ${err instanceof Error ? err.message : String(err)}`);
}
}
}
} finally {
await stmt.close();
}
// Yield to event loop between sub-batches
if (i + SUB_BATCH_SIZE < rels.length) {
await new Promise(r => setTimeout(r, 0));
}
}
}
@ -191,7 +273,7 @@ export const loadGraphToLbug = async (
for (const tableName of NODE_TABLES) {
try {
const countRes = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
const countRows = await getQueryRows(countRes);
const countRows = await countRes.getAllRows();
const countRow = countRows[0];
const count = countRow ? (countRow.cnt ?? countRow[0] ?? 0) : 0;
totalNodes += Number(count);
@ -225,12 +307,20 @@ const BACKTICK_TABLES = new Set([
'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation',
'Constructor', 'Template', 'Module',
// Reserved/ambiguous identifiers that need quoting
'File',
]);
const escapeTableName = (table: string): string => {
return BACKTICK_TABLES.has(table) ? `\`${table}\`` : table;
};
// LadybugDB DELETE needs standard quoted identifiers for reserved names (e.g., File)
const escapeTableForDelete = (table: string): string => {
if (table === 'File') return `"${table}"`;
return escapeTableName(table);
};
/** Tables with isExported column (TypeScript/JS-native types) */
const TABLES_WITH_EXPORTED = new Set<string>(['Function', 'Class', 'Interface', 'Method', 'CodeElement']);
@ -263,11 +353,20 @@ const getCopyQuery = (table: NodeTableName, path: string): string => {
* Execute a Cypher query against the database
* Returns results as named objects (not tuples) for better usability
*/
export const executeQuery = async (cypher: string): Promise<any[]> => {
export const executeQuery = async (cypher: string, readOnly = true): Promise<any[]> => {
if (!conn) {
await initLbug();
}
if (readOnly) {
// Strip quoted strings before checking for write keywords, so that
// queries like WHERE n.name CONTAINS "delete" are not blocked.
const stripped = cypher.replace(/'[^']*'|"[^"]*"/g, '').toUpperCase();
if (/\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|DETACH)\b/.test(stripped)) {
throw new Error('Read-only query attempted a write operation');
}
}
try {
const result = await conn.query(cypher);
@ -295,7 +394,7 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
}
// Collect all rows
const allRows = await getQueryRows(result);
const allRows = await result.getAllRows();
const rows: any[] = [];
for (const row of allRows) {
// Convert tuple to named object if we have column names and row is array
@ -332,7 +431,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
for (const tableName of NODE_TABLES) {
try {
const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
const nodeRows = await getQueryRows(nodeResult);
const nodeRows = await nodeResult.getAllRows();
const nodeRow = nodeRows[0];
totalNodes += Number(nodeRow?.cnt ?? nodeRow?.[0] ?? 0);
} catch {
@ -344,7 +443,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
let totalEdges = 0;
try {
const edgeResult = await conn.query(`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`);
const edgeRows = await getQueryRows(edgeResult);
const edgeRows = await edgeResult.getAllRows();
const edgeRow = edgeRows[0];
totalEdges = Number(edgeRow?.cnt ?? edgeRow?.[0] ?? 0);
} catch {
@ -384,6 +483,7 @@ export const closeLbug = async (): Promise<void> => {
db = null;
}
lbug = null;
initPromise = null;
};
/**
@ -402,17 +502,18 @@ export const executePrepared = async (
try {
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Prepare failed: ${errMsg}`);
try {
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Prepare failed: ${errMsg}`);
}
const result = await conn.execute(stmt, params);
const rows = await result.getAllRows();
return rows;
} finally {
await stmt.close();
}
const result = await conn.execute(stmt, params);
const rows = await getQueryRows(result);
await stmt.close();
return rows;
} catch (error) {
if (import.meta.env.DEV) console.error('Prepared query failed:', error);
throw error;
@ -473,7 +574,7 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str
for (const tableName of NODE_TABLES) {
try {
const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN n.id AS id LIMIT 1`);
const nodeRows = await getQueryRows(nodeResult);
const nodeRows = await nodeResult.getAllRows();
const nodeRow = nodeRows[0];
if (nodeRow) {
testNodeId = nodeRow.id ?? nodeRow[0];
@ -506,24 +607,39 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str
await stmt.close();
// Verify it was stored
const verifyResult = await conn.query(
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: '${testNodeId}'}) RETURN e.embedding AS emb`
// Verify it was stored (using prepared statement to avoid injection)
const verifyStmt = await conn.prepare(
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) RETURN e.embedding AS emb`
);
const verifyRows = await getQueryRows(verifyResult);
const verifyRow = verifyRows[0];
const storedEmb = verifyRow?.emb ?? verifyRow?.[0];
if (storedEmb && Array.isArray(storedEmb) && storedEmb.length === 384) {
if (import.meta.env.DEV) {
console.log('✅ Array params WORK! Stored embedding length:', storedEmb.length);
try {
if (!verifyStmt.isSuccess()) {
const errMsg = await verifyStmt.getErrorMessage();
return { success: false, error: `Verify prepare failed: ${errMsg}` };
}
return { success: true };
} else {
return {
success: false,
error: `Embedding not stored correctly. Got: ${typeof storedEmb}, length: ${storedEmb?.length}`
};
const verifyResult = await conn.execute(verifyStmt, { nodeId: testNodeId });
const verifyRows = await verifyResult.getAllRows();
const verifyRow = verifyRows[0];
const storedEmb = verifyRow?.emb ?? verifyRow?.[0];
// Clean up test embedding
try {
const cleanupStmt = await conn.prepare(`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`);
try { await conn.execute(cleanupStmt, { nodeId: testNodeId }); } finally { await cleanupStmt.close(); }
} catch {}
if (storedEmb && Array.isArray(storedEmb) && storedEmb.length === 384) {
if (import.meta.env.DEV) {
console.log('✅ Array params WORK! Stored embedding length:', storedEmb.length);
}
return { success: true };
} else {
return {
success: false,
error: `Embedding not stored correctly. Got: ${typeof storedEmb}, length: ${storedEmb?.length}`
};
}
} finally {
await verifyStmt.close();
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);

View file

@ -15,6 +15,13 @@ import { tool } from '@langchain/core/tools';
import { z } from 'zod';
// Note: GRAPH_SCHEMA_DESCRIPTION from './types' is available if needed for additional context
import { WebGPUNotAvailableError, embedText, embeddingToArray, initEmbedder, isEmbedderReady } from '../embeddings/embedder';
import { NODE_TABLES, REL_TYPES } from '../lbug/schema';
const validLabel = (label: string): boolean =>
(NODE_TABLES as readonly string[]).includes(label);
const validRelType = (t: string): boolean =>
(REL_TYPES as readonly string[]).includes(t);
/**
* Tool factory - creates tools bound to the LadybugDB query functions
@ -96,11 +103,12 @@ export const createGraphRAGTools = (
if (nodeId) {
try {
const nodeLabel = nodeId.split(':')[0];
if (!validLabel(nodeLabel)) throw new Error('invalid label');
const connectionsQuery = `
MATCH (n:${nodeLabel} {id: '${nodeId.replace(/'/g, "''")}'})
OPTIONAL MATCH (n)-[r1:CodeRelation]->(dst)
OPTIONAL MATCH (src)-[r2:CodeRelation]->(n)
RETURN
RETURN
collect(DISTINCT {name: dst.name, type: r1.type, confidence: r1.confidence}) AS outgoing,
collect(DISTINCT {name: src.name, type: r2.type, confidence: r2.confidence}) AS incoming
LIMIT 1
@ -136,6 +144,7 @@ export const createGraphRAGTools = (
if (nodeId) {
try {
const nodeLabel = nodeId.split(':')[0];
if (!validLabel(nodeLabel)) throw new Error('invalid label');
const clusterQuery = `
MATCH (n:${nodeLabel} {id: '${nodeId.replace(/'/g, "''")}'})
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
@ -158,6 +167,7 @@ export const createGraphRAGTools = (
if (nodeId) {
try {
const nodeLabel = nodeId.split(':')[0];
if (!validLabel(nodeLabel)) throw new Error('invalid label');
const processQuery = `
MATCH (n:${nodeLabel} {id: '${nodeId.replace(/'/g, "''")}'})
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
@ -783,7 +793,11 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
const name = getRowValue(symbolRow, 1, 'name');
const filePath = getRowValue(symbolRow, 2, 'filePath');
const nodeType = getRowValue(symbolRow, 3, 'nodeType');
if (!validLabel(nodeType)) {
return `Unknown node type "${nodeType}" for symbol "${target}".`;
}
const clusterQuery = `
MATCH (n:${nodeType} {id: '${String(nodeId).replace(/'/g, "''")}'})
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
@ -898,10 +912,13 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
// Default to usage-based relation types (exclude CONTAINS, DEFINES for impact analysis)
const defaultRelTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const activeRelTypes = relationTypes && relationTypes.length > 0
? relationTypes
const activeRelTypes = relationTypes && relationTypes.length > 0
? relationTypes.filter(t => validRelType(t))
: defaultRelTypes;
const relTypeFilter = activeRelTypes.map(t => `'${t}'`).join(', ');
if (activeRelTypes.length === 0) {
return `No valid relation types provided. Valid types: ${(REL_TYPES as readonly string[]).join(', ')}`;
}
const relTypeFilter = activeRelTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
const directionLabel = direction === 'upstream'
? 'Files that DEPEND ON this (breakage risk)'

View file

@ -26,6 +26,7 @@ export interface ProcessData {
steps: ProcessStep[];
edges?: ProcessEdge[]; // CALLS edges between steps for branching
clusters?: string[];
rawMermaid?: string; // AI-generated mermaid code (sanitized before rendering)
}
/**

View file

@ -1,7 +1,5 @@
import * as Comlink from 'comlink';
import { runIngestionPipeline, runPipelineFromFiles } from '../core/ingestion/pipeline';
import { createKnowledgeGraph } from '../core/graph/graph';
import type { GraphNode, GraphRelationship } from '../core/graph/types';
import { PipelineProgress, SerializablePipelineResult, serializePipelineResult } from '../types/pipeline';
import { FileEntry } from '../services/zip';
import {
@ -14,15 +12,17 @@ import { isEmbedderReady, disposeEmbedder } from '../core/embeddings/embedder';
import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types';
import type { ProviderConfig, AgentStreamChunk } from '../core/llm/types';
import { createGraphRAGAgent, streamAgentResponse, type AgentMessage, createChatModel } from '../core/llm/agent';
import { createKnowledgeGraph } from '../core/graph/graph';
import type { GraphNode, GraphRelationship } from '../core/graph/types';
import { SystemMessage } from '@langchain/core/messages';
import { enrichClustersBatch, ClusterMemberInfo, ClusterEnrichment } from '../core/ingestion/cluster-enricher';
import { CommunityNode } from '../core/ingestion/community-processor';
import { PipelineResult } from '../types/pipeline';
import { buildCodebaseContext, type CodebaseContext } from '../core/llm/context-builder';
import {
buildBM25Index,
searchBM25,
isBM25Ready,
import {
buildBM25Index,
searchBM25,
isBM25Ready,
getBM25Stats,
mergeWithRRF,
type HybridSearchResult,
@ -176,7 +176,7 @@ const createHttpHybridSearch = (backendUrl: string, repo: string) => {
endLine: s.endLine,
content: s.content ?? '',
sources: ['bm25', 'semantic'],
score: 1 - (i * 0.02),
score: Math.max(0, 1 - (i * 0.02)),
}));
const defs: any[] = (data.definitions ?? []).map((d: any, i: number) => ({
@ -186,7 +186,7 @@ const createHttpHybridSearch = (backendUrl: string, repo: string) => {
filePath: d.filePath,
content: '',
sources: ['bm25'],
score: 0.5 - (i * 0.02),
score: Math.max(0, 0.5 - (i * 0.02)),
}));
return [...symbols, ...defs].slice(0, k);
@ -644,6 +644,12 @@ const workerApi = {
/**
* Initialize the Graph RAG agent in backend mode (HTTP-backed tools).
* Uses HTTP wrappers instead of local LadybugDB for all tool queries.
*
* NOTE: Currently not called by any UI flow. The server-connect path
* downloads the full graph and uses local WASM queries via initializeAgent.
* This method is retained for future large-repo mode where downloading
* the entire graph to the browser would be impractical.
*
* @param config - Provider configuration for the LLM
* @param backendUrl - Base URL of the gitnexus serve backend
* @param repoName - Repository name on the backend
@ -785,8 +791,10 @@ const workerApi = {
throw new Error('No graph loaded. Please ingest a repository first.');
}
enrichmentCancelled = false;
const { graph } = currentGraphResult;
// Filter for community nodes
const communityNodes = graph.nodes
.filter(n => n.label === 'Community')
@ -808,15 +816,22 @@ const workerApi = {
// Initialize map
communityNodes.forEach(c => memberMap.set(c.id, []));
// Build a Map for O(1) node lookups instead of O(N) find per relationship
const nodeById = new Map(graph.nodes.map(n => [n.id, n]));
// Find all MEMBER_OF edges
graph.relationships.forEach(rel => {
for (const rel of graph.relationships) {
if (enrichmentCancelled) {
console.log('Enrichment cancelled, stopping');
break;
}
if (rel.type === 'MEMBER_OF') {
const communityId = rel.targetId;
const memberId = rel.sourceId; // MEMBER_OF goes Member -> Community
if (memberMap.has(communityId)) {
// Find member node details
const memberNode = graph.nodes.find(n => n.id === memberId);
const memberNode = nodeById.get(memberId);
if (memberNode) {
memberMap.get(communityId)?.push({
name: memberNode.properties.name,
@ -826,7 +841,7 @@ const workerApi = {
}
}
}
});
}
// Create LLM client adapter for LangChain model
const chatModel = createChatModel(providerConfig);
@ -864,32 +879,28 @@ const workerApi = {
}
});
// Update LadybugDB with new data
// Update LadybugDB with new data using prepared statements
try {
const lbug = await getLbugAdapter();
onProgress(enrichments.size, enrichments.size); // Done
// Update one by one via Cypher (simplest for now)
for (const [id, enrichment] of enrichments.entries()) {
// Escape strings for Cypher - replace backslash first, then quotes
const escapeCypher = (str: string) => str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const keywordsStr = JSON.stringify(enrichment.keywords);
const descStr = escapeCypher(enrichment.description);
const nameStr = escapeCypher(enrichment.name);
const escapedId = escapeCypher(id);
const query = `
MATCH (c:Community {id: "${escapedId}"})
SET c.label = "${nameStr}",
c.keywords = ${keywordsStr},
c.description = "${descStr}",
c.enrichedBy = "llm"
`;
await lbug.executeQuery(query);
}
const paramsList = Array.from(enrichments.entries()).map(([id, enrichment]) => ({
id,
label: enrichment.name,
keywords: enrichment.keywords,
description: enrichment.description,
}));
const updateQuery = `
MATCH (c:Community {id: $id})
SET c.label = $label,
c.keywords = $keywords,
c.description = $description,
c.enrichedBy = "llm"
`;
await lbug.executeWithReusedStatement(updateQuery, paramsList);
} catch (err) {
console.error('Failed to update LadybugDB with enrichment:', err);

View file

@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest';
import { NODE_TABLES, REL_TYPES } from '../../src/core/lbug/schema';
// ---------------------------------------------------------------------------
// Recreate the security guards locally so we can test the exact logic used in
// production without exporting private helpers.
//
// Source locations:
// validLabel / validRelType -- gitnexus-web/src/core/llm/tools.ts
// isSafeId -- gitnexus-web/src/components/ProcessesPanel.tsx
// readOnly guard (regex) -- gitnexus-web/src/core/lbug/lbug-adapter.ts
// ---------------------------------------------------------------------------
const validLabel = (label: string): boolean =>
(NODE_TABLES as readonly string[]).includes(label);
const validRelType = (t: string): boolean =>
(REL_TYPES as readonly string[]).includes(t);
const isSafeId = (id: string): boolean =>
/^[a-zA-Z0-9_:.\-/@]+$/.test(id);
const isWriteQuery = (cypher: string): boolean => {
const stripped = cypher.replace(/'[^']*'|"[^"]*"/g, '').toUpperCase();
return /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|DETACH)\b/.test(stripped);
};
// ===========================================================================
// validLabel
// ===========================================================================
describe('validLabel NODE_TABLES membership', () => {
it.each([
'Function', 'Class', 'File', 'Process', 'Community',
])('accepts known label "%s"', (label) => {
expect(validLabel(label)).toBe(true);
});
it.each([
'Struct', 'Enum', 'Trait', 'Impl', 'Macro', 'Typedef',
'Union', 'Namespace', 'TypeAlias', 'Const', 'Static',
'Property', 'Record', 'Delegate', 'Annotation',
'Constructor', 'Template', 'Module',
])('accepts multi-language label "%s"', (label) => {
expect(validLabel(label)).toBe(true);
});
it.each([
['empty string', ''],
['SQL keyword', 'DROP'],
['random word', 'foo'],
['Cypher injection', '})-[:R]->(x)'],
['label with semicolon', 'Function;DELETE'],
['lowercase (case matters)', 'function'],
['lowercase class', 'class'],
['whitespace padded', ' File '],
['numeric', '123'],
])('rejects invalid label: %s', (_desc, label) => {
expect(validLabel(label)).toBe(false);
});
it('NODE_TABLES contains all expected core labels', () => {
const core = ['File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process'];
for (const label of core) {
expect((NODE_TABLES as readonly string[]).includes(label)).toBe(true);
}
});
});
// ===========================================================================
// validRelType
// ===========================================================================
describe('validRelType REL_TYPES membership', () => {
it.each(
[...REL_TYPES]
)('accepts known relation type "%s"', (relType) => {
expect(validRelType(relType)).toBe(true);
});
it.each([
['empty string', ''],
['SQL keyword', 'DROP'],
['injection attempt', 'CALLS;DELETE'],
['lowercase', 'calls'],
['nonexistent type', 'FRIEND_OF'],
['padded', ' CALLS '],
])('rejects invalid relation type: %s', (_desc, relType) => {
expect(validRelType(relType)).toBe(false);
});
it('REL_TYPES has at least the base types', () => {
// Guard against accidental removal of relation types
expect(REL_TYPES.length).toBeGreaterThanOrEqual(8);
});
});
// ===========================================================================
// isSafeId
// ===========================================================================
describe('isSafeId identifier allowlist regex', () => {
it.each([
['namespaced id', 'Function:myFunc'],
['underscore id', 'proc_5'],
['class id', 'Class:MyClass'],
['dotted name', 'Module:path.to.thing'],
['with hyphen', 'File:my-file.ts'],
['community id', 'comm_5'],
['file path id', 'File:src/index.ts'],
['nested path id', 'Function:src/utils/helpers.ts:doStuff'],
['scoped npm package', 'Module:@scope/pkg'],
['angular-style id', 'Module:@angular/core'],
])('accepts valid ID: %s', (_desc, id) => {
expect(isSafeId(id)).toBe(true);
});
it.each([
['with spaces', 'Process:my process'],
])('rejects ID with unsafe chars: %s', (_desc, id) => {
expect(isSafeId(id)).toBe(false);
});
it('rejects empty string', () => {
expect(isSafeId('')).toBe(false);
});
it.each([
['SQL injection', "'; DROP TABLE"],
['command substitution', '$(command)'],
['XSS attempt', '<script>'],
['JSON injection', '{id: "x"}'],
])('rejects injection attempt: %s', (_desc, id) => {
expect(isSafeId(id)).toBe(false);
});
it.each([
['open paren', '('],
['close paren', ')'],
['open bracket', '['],
['close bracket', ']'],
['open brace', '{'],
['close brace', '}'],
['backtick', '`'],
['double quote', '"'],
['single quote', "'"],
])('rejects Cypher metacharacter: %s', (_desc, ch) => {
expect(isSafeId(ch)).toBe(false);
});
it.each([
['embedded paren', 'func(x)'],
['embedded bracket', 'arr[0]'],
['embedded brace', '{key}'],
['embedded backtick', 'id`inject'],
])('rejects id containing metacharacter: %s', (_desc, id) => {
expect(isSafeId(id)).toBe(false);
});
});
// ===========================================================================
// readOnly guard write-operation regex
// ===========================================================================
describe('readOnly guard write-operation detection', () => {
describe('allows read-only queries (should NOT match)', () => {
it.each([
['simple match', 'MATCH (n) RETURN n'],
['filtered match', 'MATCH (n:Function) WHERE n.name = "test" RETURN n'],
['with relationship', 'MATCH (a)-[r:CodeRelation]->(b) RETURN a, r, b'],
['with count', 'MATCH (n) RETURN count(n)'],
['with ordering', 'MATCH (n) RETURN n ORDER BY n.name LIMIT 10'],
['call procedure', 'CALL db.schema.nodeTypeProperties()'],
])('%s', (_desc, cypher) => {
expect(isWriteQuery(cypher)).toBe(false);
});
});
describe('blocks write operations (should match)', () => {
it.each([
['DELETE node', 'MATCH (n) DELETE n'],
['CREATE node', 'CREATE (n:Test)'],
['SET property', 'MATCH (n) SET n.x = 1'],
['MERGE node', 'MERGE (n:Test {id: "1"})'],
['REMOVE property', 'MATCH (n) REMOVE n.x'],
['DETACH DELETE', 'MATCH (n) DETACH DELETE n'],
['DROP (DDL)', 'DROP TABLE x'],
])('%s', (_desc, cypher) => {
expect(isWriteQuery(cypher)).toBe(true);
});
});
describe('handles tricky cases', () => {
it('detects write keyword even when embedded in longer query', () => {
const cypher = 'MATCH (n:Function) WHERE n.name = "handler" DELETE n';
expect(isWriteQuery(cypher)).toBe(true);
});
it('detects mixed-case write keywords via toUpperCase()', () => {
expect(isWriteQuery('match (n) delete n')).toBe(true);
expect(isWriteQuery('Match (n) Set n.x = 1')).toBe(true);
});
// Keywords inside quoted strings are stripped before checking,
// so they don't trigger false positives.
it('allows "delete" inside a quoted string value', () => {
expect(isWriteQuery('MATCH (n) WHERE n.name CONTAINS "delete" RETURN n')).toBe(false);
});
it('allows "CREATE" inside single-quoted string', () => {
expect(isWriteQuery("MATCH (n) WHERE n.name = 'CREATE_USER' RETURN n")).toBe(false);
});
it('still blocks DELETE outside quotes', () => {
expect(isWriteQuery('MATCH (n) WHERE n.name = "foo" DELETE n')).toBe(true);
});
// Verify the word-boundary prevents false positives on substrings that
// are NOT Cypher write keywords.
it('does not match partial keywords like "CREATED" or "SETTING"', () => {
expect(isWriteQuery('MATCH (n) WHERE n.status = "CREATED" RETURN n')).toBe(false);
expect(isWriteQuery('MATCH (n) WHERE n.label = "SETTING" RETURN n')).toBe(false);
});
it('does not flag the word "create" inside a property name like "createdAt"', () => {
expect(isWriteQuery('MATCH (n) RETURN n.createdAt')).toBe(false);
});
});
});