mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge pull request #475 from jreakin/fix/web-security-hardening
fix(web): Cypher injection guards, DOMPurify SVG, readOnly executeQuery
This commit is contained in:
commit
519dc3eccc
9 changed files with 524 additions and 145 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
225
gitnexus-web/test/unit/security-guards.test.ts
Normal file
225
gitnexus-web/test/unit/security-guards.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue