security.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. /**
  2. * Security Hardening Tests
  3. *
  4. * Tests for path validation, safe JSON parsing, input clamping,
  5. * file locking, and atomic config writes.
  6. */
  7. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  8. import * as fs from 'fs';
  9. import * as path from 'path';
  10. import * as os from 'os';
  11. import { validatePathWithinRoot, safeJsonParse, clamp, FileLock } from '../src/utils';
  12. import { saveConfig, loadConfig, getConfigPath } from '../src/config';
  13. import { DEFAULT_CONFIG } from '../src/types';
  14. // Create a temporary directory for each test
  15. function createTempDir(): string {
  16. return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-security-test-'));
  17. }
  18. // Clean up temporary directory
  19. function cleanupTempDir(dir: string): void {
  20. if (fs.existsSync(dir)) {
  21. fs.rmSync(dir, { recursive: true, force: true });
  22. }
  23. }
  24. describe('Security Hardening', () => {
  25. let tempDir: string;
  26. beforeEach(() => {
  27. tempDir = createTempDir();
  28. });
  29. afterEach(() => {
  30. cleanupTempDir(tempDir);
  31. });
  32. // ==========================================================================
  33. // Path Validation
  34. // ==========================================================================
  35. describe('validatePathWithinRoot', () => {
  36. it('should accept paths within the root', () => {
  37. const result = validatePathWithinRoot(tempDir, 'src/index.ts');
  38. expect(result).toBe(path.resolve(tempDir, 'src/index.ts'));
  39. });
  40. it('should accept nested paths within the root', () => {
  41. const result = validatePathWithinRoot(tempDir, 'src/db/queries.ts');
  42. expect(result).toBe(path.resolve(tempDir, 'src/db/queries.ts'));
  43. });
  44. it('should reject paths that traverse above the root', () => {
  45. const result = validatePathWithinRoot(tempDir, '../../etc/passwd');
  46. expect(result).toBeNull();
  47. });
  48. it('should reject paths with embedded traversal', () => {
  49. const result = validatePathWithinRoot(tempDir, 'src/../../etc/passwd');
  50. expect(result).toBeNull();
  51. });
  52. it('should accept the root directory itself', () => {
  53. const result = validatePathWithinRoot(tempDir, '.');
  54. expect(result).toBe(path.resolve(tempDir));
  55. });
  56. it('should reject absolute paths outside the root', () => {
  57. const outsidePath = path.resolve(tempDir, '..', 'outside-file.txt');
  58. const result = validatePathWithinRoot(tempDir, outsidePath);
  59. expect(result).toBeNull();
  60. });
  61. it('should accept absolute paths within the root', () => {
  62. const insidePath = path.join(tempDir, 'src', 'file.ts');
  63. const result = validatePathWithinRoot(tempDir, insidePath);
  64. expect(result).toBe(insidePath);
  65. });
  66. });
  67. // ==========================================================================
  68. // Safe JSON Parsing
  69. // ==========================================================================
  70. describe('safeJsonParse', () => {
  71. it('should parse valid JSON', () => {
  72. const result = safeJsonParse('{"key": "value"}', {});
  73. expect(result).toEqual({ key: 'value' });
  74. });
  75. it('should parse valid JSON arrays', () => {
  76. const result = safeJsonParse('["a", "b", "c"]', []);
  77. expect(result).toEqual(['a', 'b', 'c']);
  78. });
  79. it('should return fallback for invalid JSON', () => {
  80. const result = safeJsonParse('not valid json{{{', { default: true });
  81. expect(result).toEqual({ default: true });
  82. });
  83. it('should return fallback for empty string', () => {
  84. const result = safeJsonParse('', []);
  85. expect(result).toEqual([]);
  86. });
  87. it('should return fallback for truncated JSON', () => {
  88. const result = safeJsonParse('{"key": "val', undefined);
  89. expect(result).toBeUndefined();
  90. });
  91. it('should parse valid primitives', () => {
  92. expect(safeJsonParse('42', 0)).toBe(42);
  93. expect(safeJsonParse('"hello"', '')).toBe('hello');
  94. expect(safeJsonParse('true', false)).toBe(true);
  95. expect(safeJsonParse('null', 'fallback')).toBeNull();
  96. });
  97. });
  98. // ==========================================================================
  99. // Input Clamping
  100. // ==========================================================================
  101. describe('clamp', () => {
  102. it('should return value when within range', () => {
  103. expect(clamp(5, 1, 10)).toBe(5);
  104. });
  105. it('should clamp to minimum', () => {
  106. expect(clamp(-5, 1, 10)).toBe(1);
  107. expect(clamp(0, 1, 10)).toBe(1);
  108. });
  109. it('should clamp to maximum', () => {
  110. expect(clamp(100, 1, 10)).toBe(10);
  111. expect(clamp(11, 1, 10)).toBe(10);
  112. });
  113. it('should handle exact boundary values', () => {
  114. expect(clamp(1, 1, 10)).toBe(1);
  115. expect(clamp(10, 1, 10)).toBe(10);
  116. });
  117. it('should handle negative ranges', () => {
  118. expect(clamp(0, -10, -1)).toBe(-1);
  119. expect(clamp(-20, -10, -1)).toBe(-10);
  120. expect(clamp(-5, -10, -1)).toBe(-5);
  121. });
  122. });
  123. // ==========================================================================
  124. // File Lock
  125. // ==========================================================================
  126. describe('FileLock', () => {
  127. it('should acquire and release a lock', async () => {
  128. const lockTarget = path.join(tempDir, 'test.db');
  129. const lock = new FileLock(lockTarget);
  130. const acquired = await lock.acquire();
  131. expect(acquired).toBe(true);
  132. expect(fs.existsSync(lockTarget + '.lock')).toBe(true);
  133. lock.release();
  134. expect(fs.existsSync(lockTarget + '.lock')).toBe(false);
  135. });
  136. it('should fail to acquire when another lock is held', async () => {
  137. const lockTarget = path.join(tempDir, 'test.db');
  138. const lock1 = new FileLock(lockTarget);
  139. const lock2 = new FileLock(lockTarget);
  140. const acquired1 = await lock1.acquire();
  141. expect(acquired1).toBe(true);
  142. // Second lock should time out quickly
  143. const acquired2 = await lock2.acquire(300);
  144. expect(acquired2).toBe(false);
  145. lock1.release();
  146. });
  147. it('should allow re-acquiring after release', async () => {
  148. const lockTarget = path.join(tempDir, 'test.db');
  149. const lock = new FileLock(lockTarget);
  150. const acquired1 = await lock.acquire();
  151. expect(acquired1).toBe(true);
  152. lock.release();
  153. const acquired2 = await lock.acquire();
  154. expect(acquired2).toBe(true);
  155. lock.release();
  156. });
  157. it('should clean up stale locks', async () => {
  158. const lockTarget = path.join(tempDir, 'test.db');
  159. const lockPath = lockTarget + '.lock';
  160. // Create a stale lock file manually
  161. fs.writeFileSync(lockPath, '99999', { flag: 'wx' });
  162. // Set its mtime to 60 seconds ago
  163. const pastTime = new Date(Date.now() - 60000);
  164. fs.utimesSync(lockPath, pastTime, pastTime);
  165. const lock = new FileLock(lockTarget);
  166. // staleLockMs=1000 means locks older than 1s are considered stale
  167. const acquired = await lock.acquire(5000, 1000);
  168. expect(acquired).toBe(true);
  169. lock.release();
  170. });
  171. it('should be safe to call release when not acquired', () => {
  172. const lockTarget = path.join(tempDir, 'test.db');
  173. const lock = new FileLock(lockTarget);
  174. // Should not throw
  175. expect(() => lock.release()).not.toThrow();
  176. });
  177. it('should be safe to call release multiple times', async () => {
  178. const lockTarget = path.join(tempDir, 'test.db');
  179. const lock = new FileLock(lockTarget);
  180. await lock.acquire();
  181. lock.release();
  182. // Second release should not throw
  183. expect(() => lock.release()).not.toThrow();
  184. });
  185. it('should write PID to lock file', async () => {
  186. const lockTarget = path.join(tempDir, 'test.db');
  187. const lock = new FileLock(lockTarget);
  188. await lock.acquire();
  189. const content = fs.readFileSync(lockTarget + '.lock', 'utf-8');
  190. expect(content).toBe(String(process.pid));
  191. lock.release();
  192. });
  193. });
  194. // ==========================================================================
  195. // Atomic Config Writes
  196. // ==========================================================================
  197. describe('Atomic Config Writes', () => {
  198. it('should not leave .tmp files after save', () => {
  199. const configDir = path.join(tempDir, '.codegraph');
  200. fs.mkdirSync(configDir, { recursive: true });
  201. const config = { ...DEFAULT_CONFIG, rootDir: tempDir };
  202. saveConfig(tempDir, config);
  203. const configPath = getConfigPath(tempDir);
  204. expect(fs.existsSync(configPath)).toBe(true);
  205. expect(fs.existsSync(configPath + '.tmp')).toBe(false);
  206. });
  207. it('should produce valid JSON after atomic save', () => {
  208. const configDir = path.join(tempDir, '.codegraph');
  209. fs.mkdirSync(configDir, { recursive: true });
  210. const config = { ...DEFAULT_CONFIG, rootDir: tempDir };
  211. saveConfig(tempDir, config);
  212. const loaded = loadConfig(tempDir);
  213. expect(loaded.version).toBe(DEFAULT_CONFIG.version);
  214. expect(loaded.enableEmbeddings).toBe(DEFAULT_CONFIG.enableEmbeddings);
  215. expect(loaded.rootDir).toBe(tempDir);
  216. });
  217. it('should overwrite existing config atomically', () => {
  218. const configDir = path.join(tempDir, '.codegraph');
  219. fs.mkdirSync(configDir, { recursive: true });
  220. // Save initial config
  221. const config1 = { ...DEFAULT_CONFIG, rootDir: tempDir, maxFileSize: 100000 };
  222. saveConfig(tempDir, config1);
  223. // Overwrite with new config
  224. const config2 = { ...DEFAULT_CONFIG, rootDir: tempDir, maxFileSize: 200000 };
  225. saveConfig(tempDir, config2);
  226. const loaded = loadConfig(tempDir);
  227. expect(loaded.maxFileSize).toBe(200000);
  228. expect(fs.existsSync(getConfigPath(tempDir) + '.tmp')).toBe(false);
  229. });
  230. });
  231. });