Security hardening: path validation, input clamping, safe JSON, file locking

Implements security improvements inspired by PR #16 (credit: MO2k4):

- Add validatePathWithinRoot() to prevent path traversal attacks in
  extraction and context building
- Clamp MCP tool inputs (limit, depth, maxDepth) to sane ranges
- Use atomic writes (temp file + rename) for config saves
- Add symlink cycle detection in directory scanning to prevent infinite loops
- Replace all JSON.parse calls in db/queries.ts with safeJsonParse fallbacks
  to handle corrupted database metadata gracefully
- Add cross-process FileLock for DB write operations (indexAll, indexFiles,
  sync) to prevent concurrent writes from CLI, MCP server, and git hooks
- Remove unused path import from context/index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-02-09 23:18:40 -06:00
co-authored by Claude Opus 4.6
parent 38fac1ff28
commit 932c567d18
7 changed files with 257 additions and 55 deletions
+5 -1
View File
@@ -154,7 +154,11 @@ export function saveConfig(projectRoot: string, config: CodeGraphConfig): void {
delete (toSave as Partial<CodeGraphConfig>).rootDir;
const content = JSON.stringify(toSave, null, 2);
fs.writeFileSync(configPath, content, 'utf-8');
// Atomic write: write to temp file then rename to prevent partial/corrupt configs
const tmpPath = configPath + '.tmp';
fs.writeFileSync(tmpPath, content, 'utf-8');
fs.renameSync(tmpPath, configPath);
}
/**
+3 -3
View File
@@ -6,7 +6,6 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import {
Node,
Edge,
@@ -25,6 +24,7 @@ import { GraphTraverser } from '../graph';
import { VectorManager } from '../vectors';
import { formatContextAsMarkdown, formatContextAsJson } from './formatter';
import { logDebug, logWarn } from '../errors';
import { validatePathWithinRoot } from '../utils';
/**
* Extract likely symbol names from a natural language query
@@ -438,9 +438,9 @@ export class ContextBuilder {
* Extract code from a node's source file
*/
private async extractNodeCode(node: Node): Promise<string | null> {
const filePath = path.join(this.projectRoot, node.filePath);
const filePath = validatePathWithinRoot(this.projectRoot, node.filePath);
if (!fs.existsSync(filePath)) {
if (!filePath || !fs.existsSync(filePath)) {
return null;
}
+7 -6
View File
@@ -17,6 +17,7 @@ import {
SearchOptions,
SearchResult,
} from '../types';
import { safeJsonParse } from '../utils';
/**
* Database row types (snake_case from SQLite)
@@ -97,8 +98,8 @@ function rowToNode(row: NodeRow): Node {
isAsync: row.is_async === 1,
isStatic: row.is_static === 1,
isAbstract: row.is_abstract === 1,
decorators: row.decorators ? JSON.parse(row.decorators) : undefined,
typeParameters: row.type_parameters ? JSON.parse(row.type_parameters) : undefined,
decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined,
typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined,
updatedAt: row.updated_at,
};
}
@@ -111,7 +112,7 @@ function rowToEdge(row: EdgeRow): Edge {
source: row.source,
target: row.target,
kind: row.kind as EdgeKind,
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
metadata: row.metadata ? safeJsonParse(row.metadata, undefined) : undefined,
line: row.line ?? undefined,
column: row.col ?? undefined,
};
@@ -129,7 +130,7 @@ function rowToFileRecord(row: FileRow): FileRecord {
modifiedAt: row.modified_at,
indexedAt: row.indexed_at,
nodeCount: row.node_count,
errors: row.errors ? JSON.parse(row.errors) : undefined,
errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined,
};
}
@@ -820,7 +821,7 @@ export class QueryBuilder {
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? JSON.parse(row.candidates) : undefined,
candidates: row.candidates ? safeJsonParse<string[]>(row.candidates, []) : undefined,
}));
}
@@ -835,7 +836,7 @@ export class QueryBuilder {
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? JSON.parse(row.candidates) : undefined,
candidates: row.candidates ? safeJsonParse<string[]>(row.candidates, []) : undefined,
}));
}
+59 -1
View File
@@ -19,6 +19,7 @@ import { extractFromSource } from './tree-sitter';
import { detectLanguage, isLanguageSupported } from './grammars';
import { logDebug } from '../errors';
import { captureException } from '../sentry';
import { validatePathWithinRoot } from '../utils';
/**
* Progress callback for indexing operations
@@ -127,8 +128,22 @@ export function scanDirectory(
): string[] {
const files: string[] = [];
let count = 0;
const visitedRealPaths = new Set<string>(); // Symlink cycle detection
function walk(dir: string): void {
// Symlink cycle detection: resolve real path and skip if already visited
try {
const realDir = fs.realpathSync(dir);
if (visitedRealPaths.has(realDir)) {
logDebug('Skipping directory to prevent symlink cycle', { dir, realDir });
return;
}
visitedRealPaths.add(realDir);
} catch {
// If realpath fails, skip this directory
return;
}
// Check for .codegraphignore marker file - skip entire directory tree if present
const ignoreMarker = path.join(dir, CODEGRAPH_IGNORE_MARKER);
if (fs.existsSync(ignoreMarker)) {
@@ -149,6 +164,39 @@ export function scanDirectory(
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(rootDir, fullPath);
// Follow symlinked directories, but skip symlinked files to non-project targets
if (entry.isSymbolicLink()) {
try {
const realTarget = fs.realpathSync(fullPath);
const stat = fs.statSync(realTarget);
if (stat.isDirectory()) {
// Check exclusion, then recurse (cycle detection handles the rest)
const dirPattern = relativePath + '/';
let excluded = false;
for (const pattern of config.exclude) {
if (matchesGlob(dirPattern, pattern) || matchesGlob(relativePath, pattern)) {
excluded = true;
break;
}
}
if (!excluded) {
walk(fullPath);
}
} else if (stat.isFile()) {
if (shouldIncludeFile(relativePath, config)) {
files.push(relativePath);
count++;
if (onProgress) {
onProgress(count, relativePath);
}
}
}
} catch {
logDebug('Skipping broken symlink', { path: fullPath });
}
continue;
}
if (entry.isDirectory()) {
// Check if directory should be excluded
const dirPattern = relativePath + '/';
@@ -335,7 +383,17 @@ export class ExtractionOrchestrator {
* Index a single file
*/
async indexFile(relativePath: string): Promise<ExtractionResult> {
const fullPath = path.join(this.rootDir, relativePath);
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
if (!fullPath) {
return {
nodes: [],
edges: [],
unresolvedReferences: [],
errors: [{ message: `Path traversal blocked: ${relativePath}`, severity: 'error' }],
durationMs: 0,
};
}
// Check file exists and is readable
let content: string;
+67 -37
View File
@@ -47,7 +47,7 @@ import {
import { GraphTraverser, GraphQueryManager } from './graph';
import { VectorManager, createVectorManager, EmbeddingProgress } from './vectors';
import { ContextBuilder, createContextBuilder } from './context';
import { Mutex } from './utils';
import { Mutex, FileLock } from './utils';
// Re-export types for consumers
export * from './types';
@@ -77,7 +77,7 @@ export {
silentLogger,
defaultLogger,
} from './errors';
export { Mutex, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
export { MCPServer } from './mcp';
/**
@@ -133,9 +133,12 @@ export class CodeGraph {
private vectorManager: VectorManager | null = null;
private contextBuilder: ContextBuilder;
// Mutex for preventing concurrent indexing operations
// Mutex for preventing concurrent indexing operations (in-process)
private indexMutex = new Mutex();
// File lock for preventing concurrent writes across processes (CLI, MCP, git hooks)
private fileLock: FileLock;
private constructor(
db: DatabaseConnection,
queries: QueryBuilder,
@@ -146,6 +149,7 @@ export class CodeGraph {
this.queries = queries;
this.config = config;
this.projectRoot = projectRoot;
this.fileLock = new FileLock(db.getPath());
this.orchestrator = new ExtractionOrchestrator(projectRoot, config, queries);
this.resolver = createResolver(projectRoot, queries);
this.graphManager = new GraphQueryManager(queries);
@@ -317,6 +321,8 @@ export class CodeGraph {
* Close the CodeGraph instance and release resources
*/
close(): void {
// Release file lock if held
this.fileLock.release();
// Dispose vector manager first to release ONNX workers
if (this.vectorManager) {
this.vectorManager.dispose();
@@ -369,29 +375,37 @@ export class CodeGraph {
*/
async indexAll(options: IndexOptions = {}): Promise<IndexResult> {
return this.indexMutex.withLock(async () => {
const result = await this.orchestrator.indexAll(options.onProgress, options.signal);
const locked = await this.fileLock.acquire();
if (!locked) {
return { success: false, filesIndexed: 0, filesSkipped: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 };
}
try {
const result = await this.orchestrator.indexAll(options.onProgress, options.signal);
// Resolve references to create call/import/extends edges
if (result.success && result.filesIndexed > 0) {
// Get count of unresolved references for accurate progress
const unresolvedCount = this.queries.getUnresolvedReferences().length;
// Resolve references to create call/import/extends edges
if (result.success && result.filesIndexed > 0) {
// Get count of unresolved references for accurate progress
const unresolvedCount = this.queries.getUnresolvedReferences().length;
options.onProgress?.({
phase: 'resolving',
current: 0,
total: unresolvedCount,
});
this.resolveReferences((current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
current: 0,
total: unresolvedCount,
});
});
}
return result;
this.resolveReferences((current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
});
}
return result;
} finally {
this.fileLock.release();
}
});
}
@@ -402,7 +416,15 @@ export class CodeGraph {
*/
async indexFiles(filePaths: string[]): Promise<IndexResult> {
return this.indexMutex.withLock(async () => {
return this.orchestrator.indexFiles(filePaths);
const locked = await this.fileLock.acquire();
if (!locked) {
return { success: false, filesIndexed: 0, filesSkipped: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 };
}
try {
return this.orchestrator.indexFiles(filePaths);
} finally {
this.fileLock.release();
}
});
}
@@ -413,28 +435,36 @@ export class CodeGraph {
*/
async sync(options: IndexOptions = {}): Promise<SyncResult> {
return this.indexMutex.withLock(async () => {
const result = await this.orchestrator.sync(options.onProgress);
const locked = await this.fileLock.acquire();
if (!locked) {
return { filesChecked: 0, filesAdded: 0, filesModified: 0, filesRemoved: 0, nodesUpdated: 0, durationMs: 0 };
}
try {
const result = await this.orchestrator.sync(options.onProgress);
// Resolve references if files were updated
if (result.filesAdded > 0 || result.filesModified > 0) {
const unresolvedCount = this.queries.getUnresolvedReferences().length;
// Resolve references if files were updated
if (result.filesAdded > 0 || result.filesModified > 0) {
const unresolvedCount = this.queries.getUnresolvedReferences().length;
options.onProgress?.({
phase: 'resolving',
current: 0,
total: unresolvedCount,
});
this.resolveReferences((current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
current: 0,
total: unresolvedCount,
});
});
}
return result;
this.resolveReferences((current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
});
}
return result;
} finally {
this.fileLock.release();
}
});
}
+6 -5
View File
@@ -8,6 +8,7 @@ import CodeGraph, { findNearestCodeGraphRoot } from '../index';
import type { Node, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
import { createHash } from 'crypto';
import { writeFileSync } from 'fs';
import { clamp } from '../utils';
import { tmpdir } from 'os';
import { join } from 'path';
@@ -351,7 +352,7 @@ export class ToolHandler {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const query = args.query as string;
const kind = args.kind as string | undefined;
const limit = (args.limit as number) || 10;
const limit = clamp((args.limit as number) || 10, 1, 100);
const results = cg.searchNodes(query, {
limit,
@@ -436,7 +437,7 @@ export class ToolHandler {
private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const symbol = args.symbol as string;
const limit = (args.limit as number) || 20;
const limit = clamp((args.limit as number) || 20, 1, 100);
// First find the node by name
const results = cg.searchNodes(symbol, { limit: 1 });
@@ -463,7 +464,7 @@ export class ToolHandler {
private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const symbol = args.symbol as string;
const limit = (args.limit as number) || 20;
const limit = clamp((args.limit as number) || 20, 1, 100);
// First find the node by name
const results = cg.searchNodes(symbol, { limit: 1 });
@@ -490,7 +491,7 @@ export class ToolHandler {
private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const symbol = args.symbol as string;
const depth = (args.depth as number) || 2;
const depth = clamp((args.depth as number) || 2, 1, 10);
// First find the node by name
const results = cg.searchNodes(symbol, { limit: 1 });
@@ -574,7 +575,7 @@ export class ToolHandler {
const pattern = args.pattern as string | undefined;
const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
const includeMetadata = args.includeMetadata !== false;
const maxDepth = args.maxDepth as number | undefined;
const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
// Get all files from the index
const allFiles = cg.getFiles();
+110 -2
View File
@@ -1,13 +1,14 @@
/**
* CodeGraph Utilities
*
* Common utility functions for memory management, concurrency, and batching.
* Common utility functions for memory management, concurrency, batching,
* and security validation.
*
* @module utils
*
* @example
* ```typescript
* import { Mutex, processInBatches, MemoryMonitor } from 'codegraph';
* import { Mutex, processInBatches, MemoryMonitor, validatePathWithinRoot } from 'codegraph';
*
* // Use mutex for concurrent safety
* const mutex = new Mutex();
@@ -28,6 +29,113 @@
* ```
*/
import * as path from 'path';
import * as fs from 'fs';
// ============================================================
// SECURITY UTILITIES
// ============================================================
/**
* Validate that a resolved file path stays within the project root.
* Prevents path traversal attacks (e.g. node.filePath = "../../etc/passwd").
*
* @param projectRoot - The project root directory
* @param filePath - The relative file path to validate
* @returns The resolved absolute path, or null if it escapes the root
*/
export function validatePathWithinRoot(projectRoot: string, filePath: string): string | null {
const resolved = path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot);
if (!resolved.startsWith(normalizedRoot + path.sep) && resolved !== normalizedRoot) {
return null;
}
return resolved;
}
/**
* Safely parse JSON with a fallback value.
* Prevents crashes from corrupted database metadata.
*/
export function safeJsonParse<T>(value: string, fallback: T): T {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
/**
* Clamp a numeric value to a range.
* Used to enforce sane limits on MCP tool inputs.
*/
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Cross-process file lock using lock files.
* Prevents concurrent database writes from CLI, MCP server, and git hooks.
*/
export class FileLock {
private lockPath: string;
private acquired = false;
constructor(resourcePath: string) {
this.lockPath = resourcePath + '.lock';
}
/**
* Acquire the file lock. Waits up to timeoutMs for the lock.
* Cleans up stale locks older than staleLockMs.
*/
async acquire(timeoutMs: number = 10000, staleLockMs: number = 30000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
// Try to create lock file exclusively
fs.writeFileSync(this.lockPath, String(process.pid), { flag: 'wx' });
this.acquired = true;
return true;
} catch {
// Lock file exists - check if stale
try {
const stat = fs.statSync(this.lockPath);
if (Date.now() - stat.mtimeMs > staleLockMs) {
// Stale lock - remove and retry
fs.unlinkSync(this.lockPath);
continue;
}
} catch {
// Lock file disappeared between check and stat - retry
continue;
}
// Wait and retry
await new Promise(resolve => setTimeout(resolve, 100));
}
}
return false;
}
/**
* Release the file lock
*/
release(): void {
if (this.acquired) {
try {
fs.unlinkSync(this.lockPath);
} catch {
// Lock file already removed - that's fine
}
this.acquired = false;
}
}
}
/**
* Process items in batches to manage memory
*