GitNexus/gitnexus-web/test/unit/security-guards.test.ts
jreakin 208ade9b11 fix: add / back to isSafeId — node IDs contain file paths
Node IDs are generated as Label:filePath (e.g., Function:src/foo.ts:bar),
so forward slashes are expected in legitimate IDs. The over-tightened
regex was dropping all path-based IDs from Cypher queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:45:09 -05:00

216 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 =>
/\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|DETACH)\b/.test(cypher.toUpperCase());
// ===========================================================================
// 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'],
])('accepts valid ID: %s', (_desc, id) => {
expect(isSafeId(id)).toBe(true);
});
it.each([
['with spaces', 'Process:my process'],
['with @ symbol', 'Module:@scope/pkg'],
])('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);
});
// Known limitation: the regex matches the keyword DELETE even when it
// appears inside a string literal value. This is a deliberate tradeoff --
// false-positive blocking is safer than false-negative allowing writes.
it('flags "delete" inside a string value (known over-blocking)', () => {
const cypher = 'MATCH (n) WHERE n.name CONTAINS "delete" RETURN n';
expect(isWriteQuery(cypher)).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);
});
});
});