Skip to content

Commit 932c567

Browse files
colbymchenryclaude
andcommitted
Security hardening: path validation, input clamping, safe JSON, file locking
Implements security improvements inspired by PR colbymchenry#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>
1 parent 38fac1f commit 932c567

7 files changed

Lines changed: 257 additions & 55 deletions

File tree

src/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,11 @@ export function saveConfig(projectRoot: string, config: CodeGraphConfig): void {
154154
delete (toSave as Partial<CodeGraphConfig>).rootDir;
155155

156156
const content = JSON.stringify(toSave, null, 2);
157-
fs.writeFileSync(configPath, content, 'utf-8');
157+
158+
// Atomic write: write to temp file then rename to prevent partial/corrupt configs
159+
const tmpPath = configPath + '.tmp';
160+
fs.writeFileSync(tmpPath, content, 'utf-8');
161+
fs.renameSync(tmpPath, configPath);
158162
}
159163

160164
/**

src/context/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
*/
77

88
import * as fs from 'fs';
9-
import * as path from 'path';
109
import {
1110
Node,
1211
Edge,
@@ -25,6 +24,7 @@ import { GraphTraverser } from '../graph';
2524
import { VectorManager } from '../vectors';
2625
import { formatContextAsMarkdown, formatContextAsJson } from './formatter';
2726
import { logDebug, logWarn } from '../errors';
27+
import { validatePathWithinRoot } from '../utils';
2828

2929
/**
3030
* Extract likely symbol names from a natural language query
@@ -438,9 +438,9 @@ export class ContextBuilder {
438438
* Extract code from a node's source file
439439
*/
440440
private async extractNodeCode(node: Node): Promise<string | null> {
441-
const filePath = path.join(this.projectRoot, node.filePath);
441+
const filePath = validatePathWithinRoot(this.projectRoot, node.filePath);
442442

443-
if (!fs.existsSync(filePath)) {
443+
if (!filePath || !fs.existsSync(filePath)) {
444444
return null;
445445
}
446446

src/db/queries.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
SearchOptions,
1818
SearchResult,
1919
} from '../types';
20+
import { safeJsonParse } from '../utils';
2021

2122
/**
2223
* Database row types (snake_case from SQLite)
@@ -97,8 +98,8 @@ function rowToNode(row: NodeRow): Node {
9798
isAsync: row.is_async === 1,
9899
isStatic: row.is_static === 1,
99100
isAbstract: row.is_abstract === 1,
100-
decorators: row.decorators ? JSON.parse(row.decorators) : undefined,
101-
typeParameters: row.type_parameters ? JSON.parse(row.type_parameters) : undefined,
101+
decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined,
102+
typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined,
102103
updatedAt: row.updated_at,
103104
};
104105
}
@@ -111,7 +112,7 @@ function rowToEdge(row: EdgeRow): Edge {
111112
source: row.source,
112113
target: row.target,
113114
kind: row.kind as EdgeKind,
114-
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
115+
metadata: row.metadata ? safeJsonParse(row.metadata, undefined) : undefined,
115116
line: row.line ?? undefined,
116117
column: row.col ?? undefined,
117118
};
@@ -129,7 +130,7 @@ function rowToFileRecord(row: FileRow): FileRecord {
129130
modifiedAt: row.modified_at,
130131
indexedAt: row.indexed_at,
131132
nodeCount: row.node_count,
132-
errors: row.errors ? JSON.parse(row.errors) : undefined,
133+
errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined,
133134
};
134135
}
135136

@@ -820,7 +821,7 @@ export class QueryBuilder {
820821
referenceKind: row.reference_kind as EdgeKind,
821822
line: row.line,
822823
column: row.col,
823-
candidates: row.candidates ? JSON.parse(row.candidates) : undefined,
824+
candidates: row.candidates ? safeJsonParse<string[]>(row.candidates, []) : undefined,
824825
}));
825826
}
826827

@@ -835,7 +836,7 @@ export class QueryBuilder {
835836
referenceKind: row.reference_kind as EdgeKind,
836837
line: row.line,
837838
column: row.col,
838-
candidates: row.candidates ? JSON.parse(row.candidates) : undefined,
839+
candidates: row.candidates ? safeJsonParse<string[]>(row.candidates, []) : undefined,
839840
}));
840841
}
841842

src/extraction/index.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { extractFromSource } from './tree-sitter';
1919
import { detectLanguage, isLanguageSupported } from './grammars';
2020
import { logDebug } from '../errors';
2121
import { captureException } from '../sentry';
22+
import { validatePathWithinRoot } from '../utils';
2223

2324
/**
2425
* Progress callback for indexing operations
@@ -127,8 +128,22 @@ export function scanDirectory(
127128
): string[] {
128129
const files: string[] = [];
129130
let count = 0;
131+
const visitedRealPaths = new Set<string>(); // Symlink cycle detection
130132

131133
function walk(dir: string): void {
134+
// Symlink cycle detection: resolve real path and skip if already visited
135+
try {
136+
const realDir = fs.realpathSync(dir);
137+
if (visitedRealPaths.has(realDir)) {
138+
logDebug('Skipping directory to prevent symlink cycle', { dir, realDir });
139+
return;
140+
}
141+
visitedRealPaths.add(realDir);
142+
} catch {
143+
// If realpath fails, skip this directory
144+
return;
145+
}
146+
132147
// Check for .codegraphignore marker file - skip entire directory tree if present
133148
const ignoreMarker = path.join(dir, CODEGRAPH_IGNORE_MARKER);
134149
if (fs.existsSync(ignoreMarker)) {
@@ -149,6 +164,39 @@ export function scanDirectory(
149164
const fullPath = path.join(dir, entry.name);
150165
const relativePath = path.relative(rootDir, fullPath);
151166

167+
// Follow symlinked directories, but skip symlinked files to non-project targets
168+
if (entry.isSymbolicLink()) {
169+
try {
170+
const realTarget = fs.realpathSync(fullPath);
171+
const stat = fs.statSync(realTarget);
172+
if (stat.isDirectory()) {
173+
// Check exclusion, then recurse (cycle detection handles the rest)
174+
const dirPattern = relativePath + '/';
175+
let excluded = false;
176+
for (const pattern of config.exclude) {
177+
if (matchesGlob(dirPattern, pattern) || matchesGlob(relativePath, pattern)) {
178+
excluded = true;
179+
break;
180+
}
181+
}
182+
if (!excluded) {
183+
walk(fullPath);
184+
}
185+
} else if (stat.isFile()) {
186+
if (shouldIncludeFile(relativePath, config)) {
187+
files.push(relativePath);
188+
count++;
189+
if (onProgress) {
190+
onProgress(count, relativePath);
191+
}
192+
}
193+
}
194+
} catch {
195+
logDebug('Skipping broken symlink', { path: fullPath });
196+
}
197+
continue;
198+
}
199+
152200
if (entry.isDirectory()) {
153201
// Check if directory should be excluded
154202
const dirPattern = relativePath + '/';
@@ -335,7 +383,17 @@ export class ExtractionOrchestrator {
335383
* Index a single file
336384
*/
337385
async indexFile(relativePath: string): Promise<ExtractionResult> {
338-
const fullPath = path.join(this.rootDir, relativePath);
386+
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
387+
388+
if (!fullPath) {
389+
return {
390+
nodes: [],
391+
edges: [],
392+
unresolvedReferences: [],
393+
errors: [{ message: `Path traversal blocked: ${relativePath}`, severity: 'error' }],
394+
durationMs: 0,
395+
};
396+
}
339397

340398
// Check file exists and is readable
341399
let content: string;

src/index.ts

Lines changed: 67 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import {
4747
import { GraphTraverser, GraphQueryManager } from './graph';
4848
import { VectorManager, createVectorManager, EmbeddingProgress } from './vectors';
4949
import { ContextBuilder, createContextBuilder } from './context';
50-
import { Mutex } from './utils';
50+
import { Mutex, FileLock } from './utils';
5151

5252
// Re-export types for consumers
5353
export * from './types';
@@ -77,7 +77,7 @@ export {
7777
silentLogger,
7878
defaultLogger,
7979
} from './errors';
80-
export { Mutex, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
80+
export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
8181
export { MCPServer } from './mcp';
8282

8383
/**
@@ -133,9 +133,12 @@ export class CodeGraph {
133133
private vectorManager: VectorManager | null = null;
134134
private contextBuilder: ContextBuilder;
135135

136-
// Mutex for preventing concurrent indexing operations
136+
// Mutex for preventing concurrent indexing operations (in-process)
137137
private indexMutex = new Mutex();
138138

139+
// File lock for preventing concurrent writes across processes (CLI, MCP, git hooks)
140+
private fileLock: FileLock;
141+
139142
private constructor(
140143
db: DatabaseConnection,
141144
queries: QueryBuilder,
@@ -146,6 +149,7 @@ export class CodeGraph {
146149
this.queries = queries;
147150
this.config = config;
148151
this.projectRoot = projectRoot;
152+
this.fileLock = new FileLock(db.getPath());
149153
this.orchestrator = new ExtractionOrchestrator(projectRoot, config, queries);
150154
this.resolver = createResolver(projectRoot, queries);
151155
this.graphManager = new GraphQueryManager(queries);
@@ -317,6 +321,8 @@ export class CodeGraph {
317321
* Close the CodeGraph instance and release resources
318322
*/
319323
close(): void {
324+
// Release file lock if held
325+
this.fileLock.release();
320326
// Dispose vector manager first to release ONNX workers
321327
if (this.vectorManager) {
322328
this.vectorManager.dispose();
@@ -369,29 +375,37 @@ export class CodeGraph {
369375
*/
370376
async indexAll(options: IndexOptions = {}): Promise<IndexResult> {
371377
return this.indexMutex.withLock(async () => {
372-
const result = await this.orchestrator.indexAll(options.onProgress, options.signal);
373-
374-
// Resolve references to create call/import/extends edges
375-
if (result.success && result.filesIndexed > 0) {
376-
// Get count of unresolved references for accurate progress
377-
const unresolvedCount = this.queries.getUnresolvedReferences().length;
378+
const locked = await this.fileLock.acquire();
379+
if (!locked) {
380+
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 };
381+
}
382+
try {
383+
const result = await this.orchestrator.indexAll(options.onProgress, options.signal);
378384

379-
options.onProgress?.({
380-
phase: 'resolving',
381-
current: 0,
382-
total: unresolvedCount,
383-
});
385+
// Resolve references to create call/import/extends edges
386+
if (result.success && result.filesIndexed > 0) {
387+
// Get count of unresolved references for accurate progress
388+
const unresolvedCount = this.queries.getUnresolvedReferences().length;
384389

385-
this.resolveReferences((current, total) => {
386390
options.onProgress?.({
387391
phase: 'resolving',
388-
current,
389-
total,
392+
current: 0,
393+
total: unresolvedCount,
390394
});
391-
});
392-
}
393395

394-
return result;
396+
this.resolveReferences((current, total) => {
397+
options.onProgress?.({
398+
phase: 'resolving',
399+
current,
400+
total,
401+
});
402+
});
403+
}
404+
405+
return result;
406+
} finally {
407+
this.fileLock.release();
408+
}
395409
});
396410
}
397411

@@ -402,7 +416,15 @@ export class CodeGraph {
402416
*/
403417
async indexFiles(filePaths: string[]): Promise<IndexResult> {
404418
return this.indexMutex.withLock(async () => {
405-
return this.orchestrator.indexFiles(filePaths);
419+
const locked = await this.fileLock.acquire();
420+
if (!locked) {
421+
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 };
422+
}
423+
try {
424+
return this.orchestrator.indexFiles(filePaths);
425+
} finally {
426+
this.fileLock.release();
427+
}
406428
});
407429
}
408430

@@ -413,28 +435,36 @@ export class CodeGraph {
413435
*/
414436
async sync(options: IndexOptions = {}): Promise<SyncResult> {
415437
return this.indexMutex.withLock(async () => {
416-
const result = await this.orchestrator.sync(options.onProgress);
417-
418-
// Resolve references if files were updated
419-
if (result.filesAdded > 0 || result.filesModified > 0) {
420-
const unresolvedCount = this.queries.getUnresolvedReferences().length;
438+
const locked = await this.fileLock.acquire();
439+
if (!locked) {
440+
return { filesChecked: 0, filesAdded: 0, filesModified: 0, filesRemoved: 0, nodesUpdated: 0, durationMs: 0 };
441+
}
442+
try {
443+
const result = await this.orchestrator.sync(options.onProgress);
421444

422-
options.onProgress?.({
423-
phase: 'resolving',
424-
current: 0,
425-
total: unresolvedCount,
426-
});
445+
// Resolve references if files were updated
446+
if (result.filesAdded > 0 || result.filesModified > 0) {
447+
const unresolvedCount = this.queries.getUnresolvedReferences().length;
427448

428-
this.resolveReferences((current, total) => {
429449
options.onProgress?.({
430450
phase: 'resolving',
431-
current,
432-
total,
451+
current: 0,
452+
total: unresolvedCount,
433453
});
434-
});
435-
}
436454

437-
return result;
455+
this.resolveReferences((current, total) => {
456+
options.onProgress?.({
457+
phase: 'resolving',
458+
current,
459+
total,
460+
});
461+
});
462+
}
463+
464+
return result;
465+
} finally {
466+
this.fileLock.release();
467+
}
438468
});
439469
}
440470

0 commit comments

Comments
 (0)