memory-journal-mcp 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +131 -122
- package/.gitattributes +29 -0
- package/.github/workflows/docker-publish.yml +1 -1
- package/.github/workflows/lint-and-test.yml +1 -2
- package/.github/workflows/secrets-scanning.yml +0 -1
- package/.github/workflows/security-update.yml +6 -6
- package/.vscode/settings.json +17 -15
- package/CHANGELOG.md +1065 -11
- package/DOCKER_README.md +51 -33
- package/Dockerfile +14 -12
- package/README.md +68 -33
- package/SECURITY.md +225 -220
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/constants/ServerInstructions.d.ts +1 -1
- package/dist/constants/ServerInstructions.d.ts.map +1 -1
- package/dist/constants/ServerInstructions.js +70 -26
- package/dist/constants/ServerInstructions.js.map +1 -1
- package/dist/constants/icons.d.ts +2 -0
- package/dist/constants/icons.d.ts.map +1 -1
- package/dist/constants/icons.js +6 -0
- package/dist/constants/icons.js.map +1 -1
- package/dist/database/SqliteAdapter.d.ts +51 -10
- package/dist/database/SqliteAdapter.d.ts.map +1 -1
- package/dist/database/SqliteAdapter.js +143 -43
- package/dist/database/SqliteAdapter.js.map +1 -1
- package/dist/filtering/ToolFilter.d.ts +1 -1
- package/dist/filtering/ToolFilter.d.ts.map +1 -1
- package/dist/filtering/ToolFilter.js +7 -1
- package/dist/filtering/ToolFilter.js.map +1 -1
- package/dist/github/GitHubIntegration.d.ts +74 -2
- package/dist/github/GitHubIntegration.d.ts.map +1 -1
- package/dist/github/GitHubIntegration.js +508 -7
- package/dist/github/GitHubIntegration.js.map +1 -1
- package/dist/handlers/prompts/index.js +1 -0
- package/dist/handlers/prompts/index.js.map +1 -1
- package/dist/handlers/resources/index.d.ts.map +1 -1
- package/dist/handlers/resources/index.js +257 -13
- package/dist/handlers/resources/index.js.map +1 -1
- package/dist/handlers/tools/index.d.ts.map +1 -1
- package/dist/handlers/tools/index.js +595 -8
- package/dist/handlers/tools/index.js.map +1 -1
- package/dist/server/McpServer.d.ts +2 -0
- package/dist/server/McpServer.d.ts.map +1 -1
- package/dist/server/McpServer.js +69 -26
- package/dist/server/McpServer.js.map +1 -1
- package/dist/types/index.d.ts +97 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/logger.d.ts +1 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +8 -1
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/progress-utils.d.ts +18 -3
- package/dist/utils/progress-utils.d.ts.map +1 -1
- package/dist/utils/progress-utils.js.map +1 -1
- package/dist/utils/security-utils.d.ts +91 -0
- package/dist/utils/security-utils.d.ts.map +1 -0
- package/dist/utils/security-utils.js +184 -0
- package/dist/utils/security-utils.js.map +1 -0
- package/dist/vector/VectorSearchManager.d.ts +2 -1
- package/dist/vector/VectorSearchManager.d.ts.map +1 -1
- package/dist/vector/VectorSearchManager.js +100 -34
- package/dist/vector/VectorSearchManager.js.map +1 -1
- package/docker-compose.yml +46 -37
- package/mcp-config-example.json +0 -2
- package/package.json +21 -14
- package/releases/v4.3.1.md +69 -0
- package/releases/v4.4.0.md +120 -0
- package/server.json +3 -3
- package/src/cli.ts +11 -0
- package/src/constants/ServerInstructions.ts +70 -26
- package/src/constants/icons.ts +7 -0
- package/src/database/SqliteAdapter.ts +165 -44
- package/src/filtering/ToolFilter.ts +7 -1
- package/src/github/GitHubIntegration.ts +588 -8
- package/src/handlers/prompts/index.ts +1 -0
- package/src/handlers/resources/index.ts +318 -12
- package/src/handlers/tools/index.ts +686 -13
- package/src/server/McpServer.ts +79 -37
- package/src/types/index.ts +98 -0
- package/src/utils/logger.ts +10 -1
- package/src/utils/progress-utils.ts +17 -6
- package/src/utils/security-utils.ts +205 -0
- package/src/vector/VectorSearchManager.ts +110 -39
- package/tests/constants/icons.test.ts +102 -0
- package/tests/constants/server-instructions.test.ts +549 -0
- package/tests/database/sqlite-adapter.bench.ts +63 -0
- package/tests/database/sqlite-adapter.test.ts +555 -0
- package/tests/filtering/tool-filter.test.ts +266 -0
- package/tests/github/github-integration.test.ts +1024 -0
- package/tests/handlers/github-resource-handlers.test.ts +473 -0
- package/tests/handlers/github-tool-handlers.test.ts +556 -0
- package/tests/handlers/prompt-handlers.test.ts +91 -0
- package/tests/handlers/resource-handlers.test.ts +339 -0
- package/tests/handlers/tool-handlers.test.ts +497 -0
- package/tests/handlers/vector-tool-handlers.test.ts +238 -0
- package/tests/security/sql-injection.test.ts +347 -0
- package/tests/server/mcp-server.bench.ts +55 -0
- package/tests/server/mcp-server.test.ts +675 -0
- package/tests/utils/logger.test.ts +180 -0
- package/tests/utils/mcp-logger.test.ts +212 -0
- package/tests/utils/progress-utils.test.ts +156 -0
- package/tests/utils/security-utils.test.ts +82 -0
- package/tests/vector/vector-search-manager.test.ts +335 -0
- package/tests/vector/vector-search.bench.ts +53 -0
- package/vitest.config.ts +15 -0
- package/.github/workflows/DOCKER_DEPLOYMENT_SETUP.md +0 -387
- package/.github/workflows/dependabot-auto-merge.yml +0 -42
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vector Tool Handler Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests vector-dependent tools (semantic_search, rebuild_vector_index,
|
|
5
|
+
* add_to_vector_index, get_vector_index_stats) using a mock VectorSearchManager.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
|
9
|
+
import { callTool } from '../../src/handlers/tools/index.js'
|
|
10
|
+
import { SqliteAdapter } from '../../src/database/SqliteAdapter.js'
|
|
11
|
+
import type { VectorSearchManager } from '../../src/vector/VectorSearchManager.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a mock VectorSearchManager.
|
|
15
|
+
*/
|
|
16
|
+
function createMockVector(overrides: Partial<Record<string, unknown>> = {}): VectorSearchManager {
|
|
17
|
+
const defaults = {
|
|
18
|
+
isInitialized: vi.fn().mockReturnValue(true),
|
|
19
|
+
initialize: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
search: vi.fn().mockResolvedValue([]),
|
|
21
|
+
addEntry: vi.fn().mockResolvedValue(true),
|
|
22
|
+
removeEntry: vi.fn().mockResolvedValue(true),
|
|
23
|
+
rebuildIndex: vi.fn().mockResolvedValue(5),
|
|
24
|
+
getStats: vi.fn().mockResolvedValue({
|
|
25
|
+
itemCount: 10,
|
|
26
|
+
modelName: 'Xenova/all-MiniLM-L6-v2',
|
|
27
|
+
dimensions: 384,
|
|
28
|
+
}),
|
|
29
|
+
generateEmbedding: vi.fn().mockResolvedValue(new Array(384).fill(0)),
|
|
30
|
+
}
|
|
31
|
+
return { ...defaults, ...overrides } as unknown as VectorSearchManager
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('Vector Tool Handlers', () => {
|
|
35
|
+
let db: SqliteAdapter
|
|
36
|
+
const testDbPath = './test-vector-tools.db'
|
|
37
|
+
let entryId: number
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
db = new SqliteAdapter(testDbPath)
|
|
41
|
+
await db.initialize()
|
|
42
|
+
// Create an entry for testing
|
|
43
|
+
const entry = db.createEntry({
|
|
44
|
+
content: 'Test entry for vector tools',
|
|
45
|
+
entryType: 'personal_reflection',
|
|
46
|
+
tags: ['test'],
|
|
47
|
+
})
|
|
48
|
+
entryId = entry.id
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
db.close()
|
|
53
|
+
try {
|
|
54
|
+
const fs = require('node:fs')
|
|
55
|
+
if (fs.existsSync(testDbPath)) fs.unlinkSync(testDbPath)
|
|
56
|
+
} catch {
|
|
57
|
+
// Ignore cleanup errors
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// ========================================================================
|
|
62
|
+
// semantic_search
|
|
63
|
+
// ========================================================================
|
|
64
|
+
|
|
65
|
+
describe('semantic_search', () => {
|
|
66
|
+
it('should return results when vector manager has matches', async () => {
|
|
67
|
+
const vectorManager = createMockVector({
|
|
68
|
+
search: vi.fn().mockResolvedValue([{ entryId, score: 0.85 }]),
|
|
69
|
+
getStats: vi.fn().mockResolvedValue({ itemCount: 10 }),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const result = (await callTool(
|
|
73
|
+
'semantic_search',
|
|
74
|
+
{ query: 'test query' },
|
|
75
|
+
db,
|
|
76
|
+
vectorManager
|
|
77
|
+
)) as { query: string; entries: unknown[]; count: number }
|
|
78
|
+
|
|
79
|
+
expect(result.query).toBe('test query')
|
|
80
|
+
expect(result.count).toBe(1)
|
|
81
|
+
expect(result.entries).toHaveLength(1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should return empty with hint when index is empty', async () => {
|
|
85
|
+
const vectorManager = createMockVector({
|
|
86
|
+
search: vi.fn().mockResolvedValue([]),
|
|
87
|
+
getStats: vi.fn().mockResolvedValue({ itemCount: 0 }),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const result = (await callTool(
|
|
91
|
+
'semantic_search',
|
|
92
|
+
{ query: 'anything' },
|
|
93
|
+
db,
|
|
94
|
+
vectorManager
|
|
95
|
+
)) as { entries: unknown[]; count: number; hint: string }
|
|
96
|
+
|
|
97
|
+
expect(result.count).toBe(0)
|
|
98
|
+
expect(result.hint).toContain('rebuild_vector_index')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should return hint when no matches above threshold', async () => {
|
|
102
|
+
const vectorManager = createMockVector({
|
|
103
|
+
search: vi.fn().mockResolvedValue([]),
|
|
104
|
+
getStats: vi.fn().mockResolvedValue({ itemCount: 10 }),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const result = (await callTool(
|
|
108
|
+
'semantic_search',
|
|
109
|
+
{ query: 'nothing matches' },
|
|
110
|
+
db,
|
|
111
|
+
vectorManager
|
|
112
|
+
)) as { count: number; hint: string }
|
|
113
|
+
|
|
114
|
+
expect(result.count).toBe(0)
|
|
115
|
+
expect(result.hint).toContain('similarity_threshold')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('should suppress hint when hint_on_empty is false', async () => {
|
|
119
|
+
const vectorManager = createMockVector({
|
|
120
|
+
search: vi.fn().mockResolvedValue([]),
|
|
121
|
+
getStats: vi.fn().mockResolvedValue({ itemCount: 0 }),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const result = (await callTool(
|
|
125
|
+
'semantic_search',
|
|
126
|
+
{ query: 'query', hint_on_empty: false },
|
|
127
|
+
db,
|
|
128
|
+
vectorManager
|
|
129
|
+
)) as { count: number; hint?: string }
|
|
130
|
+
|
|
131
|
+
expect(result.count).toBe(0)
|
|
132
|
+
expect(result.hint).toBeUndefined()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should return error when no vector manager', async () => {
|
|
136
|
+
const result = (await callTool('semantic_search', { query: 'query' }, db)) as {
|
|
137
|
+
error: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
expect(result.error).toContain('not initialized')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ========================================================================
|
|
145
|
+
// rebuild_vector_index
|
|
146
|
+
// ========================================================================
|
|
147
|
+
|
|
148
|
+
describe('rebuild_vector_index', () => {
|
|
149
|
+
it('should rebuild and return count', async () => {
|
|
150
|
+
const vectorManager = createMockVector()
|
|
151
|
+
|
|
152
|
+
const result = (await callTool('rebuild_vector_index', {}, db, vectorManager)) as {
|
|
153
|
+
success: boolean
|
|
154
|
+
entriesIndexed: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
expect(result.success).toBe(true)
|
|
158
|
+
expect(result.entriesIndexed).toBe(5)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should return error when no vector manager', async () => {
|
|
162
|
+
const result = (await callTool('rebuild_vector_index', {}, db)) as { error: string }
|
|
163
|
+
|
|
164
|
+
expect(result.error).toContain('not available')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ========================================================================
|
|
169
|
+
// add_to_vector_index
|
|
170
|
+
// ========================================================================
|
|
171
|
+
|
|
172
|
+
describe('add_to_vector_index', () => {
|
|
173
|
+
it('should add entry to index', async () => {
|
|
174
|
+
const vectorManager = createMockVector()
|
|
175
|
+
|
|
176
|
+
const result = (await callTool(
|
|
177
|
+
'add_to_vector_index',
|
|
178
|
+
{ entry_id: entryId },
|
|
179
|
+
db,
|
|
180
|
+
vectorManager
|
|
181
|
+
)) as { success: boolean; entryId: number }
|
|
182
|
+
|
|
183
|
+
expect(result.success).toBe(true)
|
|
184
|
+
expect(result.entryId).toBe(entryId)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('should return error for nonexistent entry', async () => {
|
|
188
|
+
const vectorManager = createMockVector()
|
|
189
|
+
|
|
190
|
+
const result = (await callTool(
|
|
191
|
+
'add_to_vector_index',
|
|
192
|
+
{ entry_id: 99999 },
|
|
193
|
+
db,
|
|
194
|
+
vectorManager
|
|
195
|
+
)) as { error: string }
|
|
196
|
+
|
|
197
|
+
expect(result.error).toContain('not found')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should return error when no vector manager', async () => {
|
|
201
|
+
const result = (await callTool('add_to_vector_index', { entry_id: 1 }, db)) as {
|
|
202
|
+
error: string
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
expect(result.error).toContain('not available')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ========================================================================
|
|
210
|
+
// get_vector_index_stats
|
|
211
|
+
// ========================================================================
|
|
212
|
+
|
|
213
|
+
describe('get_vector_index_stats', () => {
|
|
214
|
+
it('should return stats', async () => {
|
|
215
|
+
const vectorManager = createMockVector()
|
|
216
|
+
|
|
217
|
+
const result = (await callTool('get_vector_index_stats', {}, db, vectorManager)) as {
|
|
218
|
+
available: boolean
|
|
219
|
+
itemCount: number
|
|
220
|
+
modelName: string
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
expect(result.available).toBe(true)
|
|
224
|
+
expect(result.itemCount).toBe(10)
|
|
225
|
+
expect(result.modelName).toBe('Xenova/all-MiniLM-L6-v2')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('should return unavailable when no vector manager', async () => {
|
|
229
|
+
const result = (await callTool('get_vector_index_stats', {}, db)) as {
|
|
230
|
+
available: boolean
|
|
231
|
+
error: string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
expect(result.available).toBe(false)
|
|
235
|
+
expect(result.error).toContain('not available')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
})
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL Injection Security Tests for Memory Journal MCP Server
|
|
3
|
+
*
|
|
4
|
+
* Tests the database layer's resilience against SQL injection attacks.
|
|
5
|
+
* Follows MCP Security Patterns for comprehensive coverage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
9
|
+
import { SqliteAdapter } from '../../src/database/SqliteAdapter.js'
|
|
10
|
+
import {
|
|
11
|
+
validateDateFormatPattern,
|
|
12
|
+
sanitizeSearchQuery,
|
|
13
|
+
containsSqlInjection,
|
|
14
|
+
assertNoSqlInjection,
|
|
15
|
+
assertNoPathTraversal,
|
|
16
|
+
InvalidDateFormatError,
|
|
17
|
+
SqlInjectionError,
|
|
18
|
+
PathTraversalError,
|
|
19
|
+
} from '../../src/utils/security-utils.js'
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Test Payloads
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Standard SQL injection payloads for testing.
|
|
27
|
+
* These represent common attack vectors.
|
|
28
|
+
*/
|
|
29
|
+
const INJECTION_PAYLOADS = [
|
|
30
|
+
// Stacked queries
|
|
31
|
+
"'; DROP TABLE memory_journal; --",
|
|
32
|
+
"test'; DELETE FROM tags; --",
|
|
33
|
+
"1; INSERT INTO memory_journal (content) VALUES ('hacked'); --",
|
|
34
|
+
"x'; UPDATE memory_journal SET content='pwned' WHERE 1=1; --",
|
|
35
|
+
|
|
36
|
+
// Boolean-based
|
|
37
|
+
"' OR '1'='1",
|
|
38
|
+
"' OR 1=1 --",
|
|
39
|
+
"admin' --",
|
|
40
|
+
|
|
41
|
+
// UNION-based
|
|
42
|
+
"' UNION SELECT * FROM sqlite_master --",
|
|
43
|
+
"' UNION ALL SELECT 1,2,3,4,5 --",
|
|
44
|
+
|
|
45
|
+
// Comment injection
|
|
46
|
+
"test' -- this is a comment",
|
|
47
|
+
'value /* comment */ injection',
|
|
48
|
+
|
|
49
|
+
// SQLite-specific
|
|
50
|
+
"'; ATTACH DATABASE 'malicious.db' AS mal; --",
|
|
51
|
+
"'; load_extension('malicious.so'); --",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Safe inputs that should be accepted
|
|
56
|
+
*/
|
|
57
|
+
const SAFE_INPUTS = [
|
|
58
|
+
'Normal search query',
|
|
59
|
+
"It's a valid apostrophe",
|
|
60
|
+
'SELECT is just a word here',
|
|
61
|
+
'test@email.com',
|
|
62
|
+
'100% success rate',
|
|
63
|
+
'user_name with underscore',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// Security Utility Tests
|
|
68
|
+
// ============================================================================
|
|
69
|
+
|
|
70
|
+
describe('Security Utilities', () => {
|
|
71
|
+
describe('validateDateFormatPattern', () => {
|
|
72
|
+
it('should return valid format for day', () => {
|
|
73
|
+
expect(validateDateFormatPattern('day')).toBe('%Y-%m-%d')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should return valid format for week', () => {
|
|
77
|
+
expect(validateDateFormatPattern('week')).toBe('%Y-W%W')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should return valid format for month', () => {
|
|
81
|
+
expect(validateDateFormatPattern('month')).toBe('%Y-%m')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should throw InvalidDateFormatError for invalid values', () => {
|
|
85
|
+
expect(() => validateDateFormatPattern('invalid')).toThrow(InvalidDateFormatError)
|
|
86
|
+
expect(() => validateDateFormatPattern('')).toThrow(InvalidDateFormatError)
|
|
87
|
+
expect(() => validateDateFormatPattern('%Y-%m-%d')).toThrow(InvalidDateFormatError)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should throw InvalidDateFormatError for injection attempts', () => {
|
|
91
|
+
expect(() => validateDateFormatPattern("'; DROP TABLE --")).toThrow(
|
|
92
|
+
InvalidDateFormatError
|
|
93
|
+
)
|
|
94
|
+
expect(() => validateDateFormatPattern('%Y-%m-%d) --')).toThrow(InvalidDateFormatError)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('sanitizeSearchQuery', () => {
|
|
99
|
+
it('should escape % characters', () => {
|
|
100
|
+
expect(sanitizeSearchQuery('100%')).toBe('100\\%')
|
|
101
|
+
expect(sanitizeSearchQuery('50% discount')).toBe('50\\% discount')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should escape _ characters', () => {
|
|
105
|
+
expect(sanitizeSearchQuery('user_name')).toBe('user\\_name')
|
|
106
|
+
expect(sanitizeSearchQuery('test_value_here')).toBe('test\\_value\\_here')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should escape backslashes', () => {
|
|
110
|
+
expect(sanitizeSearchQuery('path\\to\\file')).toBe('path\\\\to\\\\file')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should handle combined special characters', () => {
|
|
114
|
+
expect(sanitizeSearchQuery('50%_test\\')).toBe('50\\%\\_test\\\\')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should not modify safe strings', () => {
|
|
118
|
+
expect(sanitizeSearchQuery('normal text')).toBe('normal text')
|
|
119
|
+
expect(sanitizeSearchQuery("it's fine")).toBe("it's fine")
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('containsSqlInjection', () => {
|
|
124
|
+
it('should detect stacked query injection', () => {
|
|
125
|
+
expect(containsSqlInjection('; DROP TABLE users')).toBe(true)
|
|
126
|
+
expect(containsSqlInjection("'; DELETE FROM logs --")).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should detect comment injection', () => {
|
|
130
|
+
expect(containsSqlInjection('value -- comment')).toBe(true)
|
|
131
|
+
expect(containsSqlInjection('test /* block */ comment')).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should detect UNION injection', () => {
|
|
135
|
+
expect(containsSqlInjection("' UNION SELECT * FROM users")).toBe(true)
|
|
136
|
+
expect(containsSqlInjection("' UNION ALL SELECT 1")).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should detect boolean bypass', () => {
|
|
140
|
+
expect(containsSqlInjection("' OR '1'='1")).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should detect SQLite-specific attacks', () => {
|
|
144
|
+
expect(containsSqlInjection("ATTACH DATABASE 'mal.db' AS x")).toBe(true)
|
|
145
|
+
expect(containsSqlInjection("load_extension('evil.so')")).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should not flag safe inputs', () => {
|
|
149
|
+
for (const input of SAFE_INPUTS) {
|
|
150
|
+
expect(containsSqlInjection(input)).toBe(false)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('assertNoSqlInjection', () => {
|
|
156
|
+
it('should throw SqlInjectionError for injection attempts', () => {
|
|
157
|
+
for (const payload of INJECTION_PAYLOADS) {
|
|
158
|
+
expect(() => assertNoSqlInjection(payload)).toThrow(SqlInjectionError)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should not throw for safe inputs', () => {
|
|
163
|
+
for (const input of SAFE_INPUTS) {
|
|
164
|
+
expect(() => assertNoSqlInjection(input)).not.toThrow()
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('assertNoPathTraversal', () => {
|
|
170
|
+
it('should throw for path traversal attempts', () => {
|
|
171
|
+
expect(() => assertNoPathTraversal('../secret')).toThrow(PathTraversalError)
|
|
172
|
+
expect(() => assertNoPathTraversal('..\\secret')).toThrow(PathTraversalError)
|
|
173
|
+
expect(() => assertNoPathTraversal('/etc/passwd')).toThrow(PathTraversalError)
|
|
174
|
+
expect(() => assertNoPathTraversal('C:\\Windows')).toThrow(PathTraversalError)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('should not throw for safe filenames', () => {
|
|
178
|
+
expect(() => assertNoPathTraversal('backup.db')).not.toThrow()
|
|
179
|
+
expect(() => assertNoPathTraversal('backup_2026-02-05.db')).not.toThrow()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// SqliteAdapter SQL Injection Tests
|
|
186
|
+
// ============================================================================
|
|
187
|
+
|
|
188
|
+
describe('SqliteAdapter SQL Injection Protection', () => {
|
|
189
|
+
let db: SqliteAdapter
|
|
190
|
+
const testDbPath = './test-security.db'
|
|
191
|
+
|
|
192
|
+
beforeAll(async () => {
|
|
193
|
+
// Use temp file database for testing (SqliteAdapter always saves to disk)
|
|
194
|
+
db = new SqliteAdapter(testDbPath)
|
|
195
|
+
await db.initialize()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
afterAll(() => {
|
|
199
|
+
db.close()
|
|
200
|
+
// Clean up test database
|
|
201
|
+
try {
|
|
202
|
+
const fs = require('node:fs')
|
|
203
|
+
if (fs.existsSync(testDbPath)) {
|
|
204
|
+
fs.unlinkSync(testDbPath)
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// Ignore cleanup errors
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('createEntry - Parameterized Queries', () => {
|
|
212
|
+
it('should safely handle content with SQL injection attempts', async () => {
|
|
213
|
+
for (const payload of INJECTION_PAYLOADS) {
|
|
214
|
+
// Should not throw - parameterized queries handle this
|
|
215
|
+
const entry = db.createEntry({ content: payload })
|
|
216
|
+
expect(entry.content).toBe(payload)
|
|
217
|
+
|
|
218
|
+
// Verify the payload was stored literally, not executed
|
|
219
|
+
const retrieved = db.getEntryById(entry.id)
|
|
220
|
+
expect(retrieved?.content).toBe(payload)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('should safely handle tags with special characters', async () => {
|
|
225
|
+
const entry = db.createEntry({
|
|
226
|
+
content: 'Test entry',
|
|
227
|
+
tags: ["tag'; DROP TABLE tags; --", 'normal-tag', "tag' OR '1'='1"],
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const tags = entry.tags
|
|
231
|
+
expect(tags).toContain("tag'; DROP TABLE tags; --")
|
|
232
|
+
expect(tags).toContain('normal-tag')
|
|
233
|
+
expect(tags).toContain("tag' OR '1'='1")
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('searchEntries - LIKE Injection', () => {
|
|
238
|
+
it('should safely handle search queries with SQL injection attempts', async () => {
|
|
239
|
+
// Create a known entry
|
|
240
|
+
db.createEntry({ content: 'Known safe content' })
|
|
241
|
+
|
|
242
|
+
for (const payload of INJECTION_PAYLOADS) {
|
|
243
|
+
// Should not throw - parameterized queries handle this
|
|
244
|
+
const results = db.searchEntries(payload)
|
|
245
|
+
// Results should be based on LIKE matching, not SQL execution
|
|
246
|
+
expect(Array.isArray(results)).toBe(true)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('should handle special LIKE characters in queries', async () => {
|
|
251
|
+
// Create entries with special characters
|
|
252
|
+
db.createEntry({ content: '50% discount on items' })
|
|
253
|
+
db.createEntry({ content: 'user_name property' })
|
|
254
|
+
|
|
255
|
+
// Search for literal special characters
|
|
256
|
+
const percentResults = db.searchEntries('50%')
|
|
257
|
+
const underscoreResults = db.searchEntries('user_name')
|
|
258
|
+
|
|
259
|
+
expect(Array.isArray(percentResults)).toBe(true)
|
|
260
|
+
expect(Array.isArray(underscoreResults)).toBe(true)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
describe('getStatistics - Date Format Validation', () => {
|
|
265
|
+
it('should only accept valid groupBy values', async () => {
|
|
266
|
+
// Valid values should work
|
|
267
|
+
expect(() => db.getStatistics('day')).not.toThrow()
|
|
268
|
+
expect(() => db.getStatistics('week')).not.toThrow()
|
|
269
|
+
expect(() => db.getStatistics('month')).not.toThrow()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('should reject invalid groupBy values', async () => {
|
|
273
|
+
// Type system prevents this at compile time, but runtime validation
|
|
274
|
+
// should also protect against invalid values
|
|
275
|
+
expect(() => db.getStatistics("'; DROP TABLE memory_journal; --" as 'day')).toThrow()
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('updateEntry - Parameterized Updates', () => {
|
|
280
|
+
it('should safely handle updates with injection attempts', async () => {
|
|
281
|
+
const entry = db.createEntry({ content: 'Original content' })
|
|
282
|
+
|
|
283
|
+
const updated = db.updateEntry(entry.id, {
|
|
284
|
+
content: "'; DELETE FROM memory_journal; --",
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
expect(updated?.content).toBe("'; DELETE FROM memory_journal; --")
|
|
288
|
+
// Database should still be intact
|
|
289
|
+
const stats = db.getStatistics('day')
|
|
290
|
+
expect(stats.totalEntries).toBeGreaterThan(0)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
describe('linkEntries - Relationship Injection', () => {
|
|
295
|
+
it('should safely handle descriptions with injection attempts', async () => {
|
|
296
|
+
const entry1 = db.createEntry({ content: 'Entry 1' })
|
|
297
|
+
const entry2 = db.createEntry({ content: 'Entry 2' })
|
|
298
|
+
|
|
299
|
+
const relationship = db.linkEntries(
|
|
300
|
+
entry1.id,
|
|
301
|
+
entry2.id,
|
|
302
|
+
'references',
|
|
303
|
+
"'; DROP TABLE relationships; --"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
expect(relationship.description).toBe("'; DROP TABLE relationships; --")
|
|
307
|
+
|
|
308
|
+
// Verify relationships table still exists
|
|
309
|
+
const rels = db.getRelationships(entry1.id)
|
|
310
|
+
expect(Array.isArray(rels)).toBe(true)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('restoreFromFile - Path Traversal Protection', () => {
|
|
315
|
+
it('should reject filenames with path traversal', async () => {
|
|
316
|
+
await expect(db.restoreFromFile('../../../etc/passwd')).rejects.toThrow(
|
|
317
|
+
'Invalid backup filename: path separators not allowed'
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
await expect(db.restoreFromFile('..\\..\\windows\\system32')).rejects.toThrow(
|
|
321
|
+
'Invalid backup filename: path separators not allowed'
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
await expect(db.restoreFromFile('/etc/passwd')).rejects.toThrow(
|
|
325
|
+
'Invalid backup filename: path separators not allowed'
|
|
326
|
+
)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
describe('Database Integrity After Attacks', () => {
|
|
331
|
+
it('should maintain database integrity after all injection attempts', async () => {
|
|
332
|
+
// Verify core tables still exist
|
|
333
|
+
const stats = db.getStatistics('day')
|
|
334
|
+
expect(stats.totalEntries).toBeGreaterThan(0)
|
|
335
|
+
|
|
336
|
+
// Verify we can still perform normal operations
|
|
337
|
+
const newEntry = db.createEntry({ content: 'Integrity check entry' })
|
|
338
|
+
expect(newEntry.id).toBeDefined()
|
|
339
|
+
|
|
340
|
+
const retrieved = db.getEntryById(newEntry.id)
|
|
341
|
+
expect(retrieved?.content).toBe('Integrity check entry')
|
|
342
|
+
|
|
343
|
+
const tags = db.listTags()
|
|
344
|
+
expect(Array.isArray(tags)).toBe(true)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { bench, beforeAll, afterAll } from 'vitest'
|
|
2
|
+
import { getTools, callTool } from '../../src/handlers/tools/index.js'
|
|
3
|
+
import { SqliteAdapter } from '../../src/database/SqliteAdapter.js'
|
|
4
|
+
import * as fs from 'node:fs'
|
|
5
|
+
import * as path from 'node:path'
|
|
6
|
+
|
|
7
|
+
let db: SqliteAdapter
|
|
8
|
+
const testDbPath = path.join(process.cwd(), 'benchmark-server-tools.db')
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
db = new SqliteAdapter(testDbPath)
|
|
12
|
+
await db.initialize()
|
|
13
|
+
|
|
14
|
+
// Setup initial data
|
|
15
|
+
for (let i = 0; i < 1000; i++) {
|
|
16
|
+
db.createEntry({
|
|
17
|
+
content: `Server benchmark tool entry ${i} ${Math.random().toString(36)}`,
|
|
18
|
+
entryType: 'decision',
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
db.close()
|
|
25
|
+
try {
|
|
26
|
+
if (fs.existsSync(testDbPath)) {
|
|
27
|
+
fs.unlinkSync(testDbPath)
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Ignore signup errors
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
bench('getTools', () => {
|
|
35
|
+
getTools(db, null)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
bench('callTool create_entry', async () => {
|
|
39
|
+
await callTool(
|
|
40
|
+
'create_entry',
|
|
41
|
+
{
|
|
42
|
+
content: 'Bench creation entry text over a tool call',
|
|
43
|
+
entry_type: 'personal_reflection',
|
|
44
|
+
},
|
|
45
|
+
db
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
bench('callTool get_recent_entries', async () => {
|
|
50
|
+
await callTool('get_recent_entries', { limit: 50 }, db)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
bench('callTool search_entries', async () => {
|
|
54
|
+
await callTool('search_entries', { query: 'benchmark', limit: 20 }, db)
|
|
55
|
+
})
|