GitNexus/gitnexus/test/unit/security.test.ts
Gergő Magyar 11a3d0515c
feat: Phase 8 field/property type resolution (#354)
* feat: Phase 8 field/property type resolution — resolve chained member access

Add field/property type extraction to the type resolution system so that
chained member access like `user.address.save()` resolves the intermediate
receiver type (`address → Address`) through Property symbols in SymbolTable.

Key changes:
- SymbolTable: add `declaredType` field, `fieldByOwner` O(1) index,
  `lookupFieldByOwner()` method, P0 conditional callableIndex invalidation,
  P2 exclude Properties from globalIndex to prevent namespace pollution
- tree-sitter queries: add `definition.property` for TypeScript, Java, Go
- parse-worker: extract declared types for Property nodes via
  `extractPropertyDeclaredType()`, capture field-access receiver info
- call-processor: add `resolveFieldAccessType()` helper and field-access
  branch in both sequential and worker receiver resolution paths
- Integration tests: new field-types test suite verifying end-to-end
  `user.address.save() → Address#save` resolution

* fix: Go tree-sitter query captures field_declaration not field_declaration_list

Post-review fix: the Go struct field query incorrectly put @definition.property
on field_declaration_list (the list container) instead of field_declaration
(the individual field). Also removed unused `language` parameter from
extractPropertyDeclaredType.

* feat: expand field-type tests to 6 languages, fix Go ownerId and Kotlin navigation_expression

- Add integration test fixtures for Java, C#, Go, Kotlin, PHP (alongside existing TS)
- Fix Go: add type_declaration handling in findEnclosingClassId for struct fields
  (field_declaration → field_declaration_list → struct_type → type_spec → type_declaration)
- Fix Kotlin: add navigation_expression handling in field-access resolution
  (Kotlin uses navigation_expression + navigation_suffix, not member_expression)
- Add extractMemberAccessParts helper in call-processor for cross-language member access
- All 24 field-type tests pass across 6 languages, 181 Go+Kotlin tests pass with no regressions

* refactor: split HAS_METHOD into HAS_METHOD + HAS_PROPERTY edge types

Property nodes now use HAS_PROPERTY edges instead of HAS_METHOD, giving
the graph schema proper semantic separation between methods and fields.

- HAS_METHOD: Method, Constructor, Function (when inside a class)
- HAS_PROPERTY: Property nodes (class fields, struct fields, attributes)

MRO processor only reads HAS_METHOD — properties correctly excluded from
method resolution order. Impact analysis accepts both edge types.

Updated 12 files: graph types, schema, tools docs, parse-worker,
parsing-processor, call-processor, and 6 test files.

* fix(test): update security test to expect 7 VALID_RELATION_TYPES (added HAS_PROPERTY)

* test: add unit tests for Phase 8 SymbolTable features (39 tests, up from 19)

Cover all new branches: declaredType metadata, Property exclusion from
globalIndex, conditional callableIndex invalidation, lookupFieldByOwner
(happy path + edge cases), lookupFuzzyCallable filtering, and clear()
with fieldByOwner. Fixes branch coverage threshold (21.8% → 23%+).

* feat: Phase 8B mixed field+method chain resolution, C++/Rust chain fixes

Unify field and method chain resolution into a single `extractMixedChain`
walker that handles interleaved patterns like `svc.getUser().address.save()`.
Fix C++ chain calls (tree-sitter-cpp `field_expression` uses `argument` not
`object`), Rust unit struct instantiation (`let svc = TypeName;`), and add
stdlib passthrough for `unwrap()`/`clone()`/`expect()` in chain loops.

Key changes:
- Replace `receiverCallChain` + `receiverFieldAccess` with unified
  `receiverMixedChain: MixedChainStep[]` on ExtractedCall
- Add `extractMixedChain` in utils.ts (handles both call_expression and
  field_expression nodes, including C++ `argument` field)
- Add `TYPE_PRESERVING_METHODS` set for stdlib identity operations
- Add C++ inline method double-indexing guard in parsing-processor.ts
  and parse-worker.ts
- Add Rust unit struct recognition in type-extractors/rust.ts
- Split field-types.test.ts into per-language test files
- Add ts-mixed-chain fixture and integration tests
- Resolve rust.test.ts todo: Option<T>.unwrap().save() now works
- Update roadmap: Phases 7+8 complete, Phase 9 is next

* fix: Python declaredType extraction and sequential-path property registration

- Move @definition.property capture from expression_statement to assignment
  node in Python queries so Strategy 1 childForFieldName('type') succeeds
- Pass item.declaredType through ctx.symbols.add in sequential call-processor
  path, matching worker path behavior (fixes Ruby YARD declaredType drop)
- Add Python chain resolution integration test (user.address.save → Address#save)
- Update Rust/Python status in roadmap and system docs to reflect actual coverage

* fix: Python/Ruby field type disambiguation and Rust chain test

Three fixes from PR #354 third review:

1. Python typed_parameter name extraction: tree-sitter-python's
   typed_parameter uses positional children for the name, not a named
   field. TypeEnv and extractParameter now fall back to firstNamedChild.

2. Ruby/Python call-step field resolution: Ruby's AST uses `call` nodes
   for both property access and method calls. The chain walker now tries
   resolveFieldAccessType before resolveCallTarget for call steps, so
   attr_accessor properties resolve via declaredType.

3. Rust chain resolution test: added missing integration test asserting
   user.address.save() resolves to Address#save.

Also splits C/C++ and TS/JS columns in type-resolution-system.md
language matrix with footnotes for accuracy.

1062 resolver integration tests passing, 0 failures.

* refactor: Phase 8 code review cleanup — extract walkMixedChain, fix MCP agent gaps

- Extract duplicated chain resolution loop into shared walkMixedChain() helper,
  eliminating ~60 lines of copy-pasted code between sequential and worker paths
- Add returnType to ResolveResult, removing redundant lookupFuzzy+find per chain step
- Fix context() tool to include HAS_METHOD, HAS_PROPERTY, OVERRIDES in queries
  so agents can discover class members
- Fix p.declaredType Cypher example (column doesn't exist) → p.description
- Add HAS_METHOD, HAS_PROPERTY, OVERRIDES to schema resource
- Document HAS_METHOD/HAS_PROPERTY in impact tool description
- Delete dead code extractMemberAccessParts (superseded by extractMixedChain)
- Replace any with SyntaxNode on extractPropertyDeclaredType
- Add Rust deep-field-chain test (5 tests), Java mixed-chain (4), Go mixed-chain (4)
- All 1075 tests pass (13 new, 0 regressions)

* refactor: type SymbolDefinition.type as NodeLabel, add O(1) receiver index

- Change SymbolDefinition.type from string to NodeLabel union (35 members)
  across symbol-table.ts, parse-worker.ts, parsing-processor.ts — compiler
  now enforces correctness at all comparison/assignment sites
- Replace O(N*M) linear scan in lookupReceiverType with pre-built
  ReceiverTypeIndex (Map<funcName, Map<varName, Entry>>) for O(1) lookups
  with proper ambiguity handling and file-level fallback
- All 1075 tests pass, 0 regressions

* fix: capture C++ pointer/ref fields, Kotlin data class props, PHP constructor promotion

Add tree-sitter query patterns for three previously missed property declaration
forms: C++ pointer/reference member fields (Address* addr; Address& ref;),
Kotlin primary constructor val/var parameters (data class User(val name: String)),
and PHP 8.0+ constructor property promotion (public Address $address).

Fix "10 languages" off-by-one in docs (Ruby is single-level only, not deep chain).
Update Python feature matrix cell from No* to Yes* after 31b95f0 fix.

11 new integration tests with per-language fixtures verify property capture,
HAS_PROPERTY edge emission, and field-access chain resolution.
2026-03-18 18:47:33 +00:00

193 lines
7.2 KiB
TypeScript

/**
* P0 Unit Tests: Security Hardening
*
* Tests all security hardening in isolation:
* - Write blocking (CYPHER_WRITE_RE)
* - Relation type allowlist
* - Path traversal detection
* - isWriteQuery wrapper
* - isTestFilePath patterns
*/
import { describe, it, expect } from 'vitest';
import {
CYPHER_WRITE_RE,
VALID_RELATION_TYPES,
VALID_NODE_LABELS,
isWriteQuery,
isTestFilePath,
} from '../../src/mcp/local/local-backend.js';
// ─── Write-operation blocking (CYPHER_WRITE_RE) ──────────────────────
describe('CYPHER_WRITE_RE', () => {
const writeKeywords = ['CREATE', 'DELETE', 'SET', 'MERGE', 'REMOVE', 'DROP', 'ALTER', 'COPY', 'DETACH'];
for (const keyword of writeKeywords) {
it(`matches "${keyword}" (uppercase)`, () => {
expect(CYPHER_WRITE_RE.test(`${keyword} (n:Node)`)).toBe(true);
});
it(`matches "${keyword.toLowerCase()}" (lowercase)`, () => {
expect(CYPHER_WRITE_RE.test(`${keyword.toLowerCase()} (n:Node)`)).toBe(true);
});
it(`matches "${keyword[0] + keyword.slice(1).toLowerCase()}" (mixed case)`, () => {
const mixed = keyword[0] + keyword.slice(1).toLowerCase();
expect(CYPHER_WRITE_RE.test(`${mixed} (n:Node)`)).toBe(true);
});
}
// Safe read queries should NOT be blocked
const safeQueries = [
'MATCH (n) RETURN n',
'MATCH (n:Function) WHERE n.name = "foo" RETURN n',
'MATCH (a)-[r]->(b) RETURN a, r, b',
'OPTIONAL MATCH (n)-[r]->(m) RETURN n, r, m',
'MATCH (n) WITH n RETURN n.name',
'UNWIND [1,2,3] AS x RETURN x',
'MATCH (n) RETURN count(n)',
'MATCH (n:Function) WHERE n.filePath CONTAINS "test" RETURN n',
];
for (const query of safeQueries) {
it(`does NOT block safe query: "${query.slice(0, 50)}..."`, () => {
expect(CYPHER_WRITE_RE.test(query)).toBe(false);
});
}
it('blocks write keyword within a longer query', () => {
expect(CYPHER_WRITE_RE.test('MATCH (n) DELETE n')).toBe(true);
expect(CYPHER_WRITE_RE.test('MATCH (n:Node) SET n.name = "x"')).toBe(true);
});
it('does not match partial word (e.g., "CREATED" should not match)', () => {
// \b ensures word boundary. "CREATED" starts with "CREATE" but has extra D
// Actually \b(CREATE) matches "CREATE" in "CREATED" since CREATE is followed by D
// which is a word char -> no boundary at E-D. Let's verify:
expect(CYPHER_WRITE_RE.test('CREATED_AT')).toBe(false);
});
});
// ─── isWriteQuery wrapper ─────────────────────────────────────────────
describe('isWriteQuery', () => {
it('returns true for write queries', () => {
expect(isWriteQuery('CREATE (n:Node)')).toBe(true);
expect(isWriteQuery('match (n) delete n')).toBe(true);
});
it('returns false for read queries', () => {
expect(isWriteQuery('MATCH (n) RETURN n')).toBe(false);
});
it('handles empty string', () => {
expect(isWriteQuery('')).toBe(false);
});
// Hardening: regex lastIndex not stuck (non-global regex, but verify)
it('works correctly on consecutive calls', () => {
expect(isWriteQuery('CREATE (n)')).toBe(true);
expect(isWriteQuery('MATCH (n) RETURN n')).toBe(false);
expect(isWriteQuery('DROP TABLE foo')).toBe(true);
expect(isWriteQuery('MATCH (n) RETURN n')).toBe(false);
});
});
// ─── Relation type allowlist ──────────────────────────────────────────
describe('VALID_RELATION_TYPES', () => {
it('contains exactly the expected 7 types', () => {
expect(VALID_RELATION_TYPES.size).toBe(7);
expect(VALID_RELATION_TYPES.has('CALLS')).toBe(true);
expect(VALID_RELATION_TYPES.has('IMPORTS')).toBe(true);
expect(VALID_RELATION_TYPES.has('EXTENDS')).toBe(true);
expect(VALID_RELATION_TYPES.has('IMPLEMENTS')).toBe(true);
expect(VALID_RELATION_TYPES.has('HAS_METHOD')).toBe(true);
expect(VALID_RELATION_TYPES.has('HAS_PROPERTY')).toBe(true);
expect(VALID_RELATION_TYPES.has('OVERRIDES')).toBe(true);
});
it('rejects invalid relation types', () => {
expect(VALID_RELATION_TYPES.has('CONTAINS')).toBe(false);
expect(VALID_RELATION_TYPES.has('USES')).toBe(false);
expect(VALID_RELATION_TYPES.has('calls')).toBe(false); // case-sensitive
expect(VALID_RELATION_TYPES.has('DROP_TABLE')).toBe(false);
});
});
// ─── Valid node labels ───────────────────────────────────────────────
describe('VALID_NODE_LABELS', () => {
it('contains core node types', () => {
for (const label of ['File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement']) {
expect(VALID_NODE_LABELS.has(label)).toBe(true);
}
});
it('contains meta node types', () => {
for (const label of ['Community', 'Process']) {
expect(VALID_NODE_LABELS.has(label)).toBe(true);
}
});
it('contains multi-language node types', () => {
for (const label of ['Struct', 'Enum', 'Macro', 'Trait', 'Impl', 'Namespace']) {
expect(VALID_NODE_LABELS.has(label)).toBe(true);
}
});
it('rejects invalid labels', () => {
expect(VALID_NODE_LABELS.has('InvalidType')).toBe(false);
expect(VALID_NODE_LABELS.has('function')).toBe(false); // case-sensitive
});
});
// ─── Path traversal detection ────────────────────────────────────────
describe('path traversal (isTestFilePath as proxy for path handling)', () => {
it('isTestFilePath matches .test. files', () => {
expect(isTestFilePath('src/foo.test.ts')).toBe(true);
expect(isTestFilePath('src/foo.spec.ts')).toBe(true);
});
it('isTestFilePath matches __tests__ directory', () => {
expect(isTestFilePath('src/__tests__/foo.ts')).toBe(true);
});
it('isTestFilePath matches /test/ directory', () => {
expect(isTestFilePath('src/test/foo.ts')).toBe(true);
});
it('isTestFilePath handles Windows backslash paths', () => {
expect(isTestFilePath('src\\test\\foo.ts')).toBe(true);
expect(isTestFilePath('src\\__tests__\\bar.ts')).toBe(true);
});
it('isTestFilePath is case-insensitive', () => {
expect(isTestFilePath('SRC/TEST/Foo.ts')).toBe(true);
expect(isTestFilePath('SRC/Foo.Test.ts')).toBe(true);
});
it('isTestFilePath matches Go test files', () => {
expect(isTestFilePath('pkg/handler_test.go')).toBe(true);
});
it('isTestFilePath matches Python test files', () => {
expect(isTestFilePath('tests/test_handler.py')).toBe(true);
expect(isTestFilePath('pkg/handler_test.py')).toBe(true);
});
it('isTestFilePath returns false for non-test files', () => {
expect(isTestFilePath('src/main.ts')).toBe(false);
expect(isTestFilePath('src/utils/helper.ts')).toBe(false);
});
});
// ─── Static analysis: parameterized query patterns ────────────────────
describe('parameterized query patterns (static analysis)', () => {
it('CYPHER_WRITE_RE is not a global regex (no lastIndex issue)', () => {
// A global regex would have sticky lastIndex state
expect(CYPHER_WRITE_RE.global).toBe(false);
});
});