claude-security-api.test.js
1 /** 2 * Claude Security API Tests 3 * 4 * Tests for Claude-powered security analysis, threat modeling, and fix generation 5 */ 6 7 import { test } from 'node:test'; 8 import assert from 'node:assert'; 9 // createPgMock is the standard pg-mock helper (used here for consistency with the test suite) 10 import { createPgMock } from '../helpers/pg-mock.js'; // eslint-disable-line no-unused-vars 11 import { 12 callClaude, 13 analyzeCodeSecurity, 14 generateSecureFix, 15 performThreatModeling, 16 } from '../../src/agents/utils/claude-api.js'; 17 18 // Skip integration tests if OPENROUTER_API_KEY not configured 19 const hasApiKey = !!process.env.OPENROUTER_API_KEY; 20 21 test('callClaude - validates required parameters', async () => { 22 const originalKey = process.env.OPENROUTER_API_KEY; 23 24 // Test missing API key 25 delete process.env.OPENROUTER_API_KEY; 26 await assert.rejects( 27 async () => await callClaude({ prompt: 'test' }), 28 /OPENROUTER_API_KEY not configured/ 29 ); 30 31 // Test missing prompt 32 process.env.OPENROUTER_API_KEY = originalKey || 'test-key'; 33 await assert.rejects(async () => await callClaude({}), /Prompt is required/); 34 35 process.env.OPENROUTER_API_KEY = originalKey; 36 }); 37 38 test('analyzeCodeSecurity - SQL injection detection', { skip: !hasApiKey }, async () => { 39 const vulnerableCode = ` 40 import Database from 'better-sqlite3'; 41 const db = new Database('data.db'); 42 const userId = req.params.id; 43 const result = db.exec(\`SELECT * FROM users WHERE id = \${userId}\`); 44 `; 45 46 const analysis = await analyzeCodeSecurity(vulnerableCode, 'sql_injection', 'api.js'); 47 48 assert.ok(analysis.findings, 'Should return findings'); 49 assert.ok(Array.isArray(analysis.findings), 'Findings should be array'); 50 assert.ok(analysis.summary, 'Should return summary'); 51 52 // Should detect the SQL injection 53 if (analysis.findings.length > 0) { 54 assert.ok(analysis.findings[0].type); 55 assert.ok(analysis.findings[0].severity); 56 assert.ok(['critical', 'high', 'medium', 'low'].includes(analysis.findings[0].severity)); 57 } 58 }); 59 60 test('generateSecureFix - validates parameters', async () => { 61 await assert.rejects( 62 async () => await generateSecureFix({ code: 'test' }), 63 /Cannot read properties of undefined/ 64 ); 65 66 await assert.rejects( 67 async () => await generateSecureFix({ finding: {} }), 68 err => { 69 // finding.type is undefined, either causes a runtime error or API error when code is missing 70 assert.ok(err instanceof Error, 'Should throw an Error'); 71 return true; 72 } 73 ); 74 }); 75 76 test('generateSecureFix - produces fix for SQL injection', { skip: !hasApiKey }, async () => { 77 const vulnerableCode = `const result = db.exec(\`SELECT * FROM users WHERE id = \${userId}\`);`; 78 79 const finding = { 80 type: 'sql_injection', 81 severity: 'critical', 82 line: 1, 83 description: 'SQL injection via string interpolation', 84 recommendation: 'Use parameterized queries with placeholders', 85 }; 86 87 const fix = await generateSecureFix({ 88 code: vulnerableCode, 89 finding, 90 fileName: 'test.js', 91 }); 92 93 assert.ok(fix.old_string, 'Should have old_string'); 94 assert.ok(fix.new_string, 'Should have new_string'); 95 assert.ok(fix.explanation, 'Should have explanation'); 96 assert.ok(fix.testing_notes, 'Should have testing notes'); 97 98 // old_string should be in vulnerable code 99 assert.ok(vulnerableCode.includes(fix.old_string.trim())); 100 101 // new_string should be different 102 assert.notStrictEqual(fix.old_string, fix.new_string); 103 }); 104 105 test('performThreatModeling - validates component parameter', async () => { 106 await assert.rejects( 107 async () => await performThreatModeling({}), 108 /[\s\S]+/ // any error - component is undefined which causes API or runtime error 109 ); 110 }); 111 112 test('performThreatModeling - STRIDE analysis', { skip: !hasApiKey }, async () => { 113 const component = ` 114 // Authentication endpoint 115 app.post('/api/login', (req, res) => { 116 const { username, password } = req.body; 117 // SQL injection vulnerability 118 const user = db.query(\`SELECT * FROM users WHERE username = '\${username}'\`)[0]; 119 120 // Weak password comparison 121 if (user && user.password === password) { 122 // No session management 123 res.json({ success: true, userId: user.id }); 124 } else { 125 res.status(401).json({ error: 'Invalid credentials' }); 126 } 127 }); 128 `; 129 130 const result = await performThreatModeling({ 131 component, 132 componentType: 'auth', 133 dataFlow: 'Client → API → Database → Response', 134 }); 135 136 assert.ok(result.threats, 'Should return threats'); 137 assert.ok(Array.isArray(result.threats), 'Threats should be array'); 138 assert.ok(result.summary, 'Should have summary'); 139 140 if (result.threats.length > 0) { 141 const threat = result.threats[0]; 142 143 // Validate STRIDE category 144 assert.ok(threat.stride_category, 'Should have STRIDE category'); 145 const validStride = [ 146 'Spoofing', 147 'Tampering', 148 'Repudiation', 149 'InformationDisclosure', 150 'DoS', 151 'ElevationOfPrivilege', 152 ]; 153 assert.ok(validStride.includes(threat.stride_category)); 154 155 // Validate threat structure 156 assert.ok(threat.title); 157 assert.ok(threat.description); 158 assert.ok(threat.attack_scenario); 159 assert.ok(threat.mitigation); 160 161 // Validate DREAD scores 162 assert.ok(threat.dread); 163 assert.ok(typeof threat.dread.damage === 'number'); 164 assert.ok(typeof threat.dread.reproducibility === 'number'); 165 assert.ok(typeof threat.dread.exploitability === 'number'); 166 assert.ok(typeof threat.dread.affected_users === 'number'); 167 assert.ok(typeof threat.dread.discoverability === 'number'); 168 169 // DREAD scores should be 1-10 170 assert.ok(threat.dread.damage >= 1 && threat.dread.damage <= 10); 171 172 // Validate risk level 173 assert.ok(threat.risk_level); 174 assert.ok(['critical', 'high', 'medium', 'low'].includes(threat.risk_level)); 175 } 176 }); 177 178 test('callClaude - accepts custom model', { skip: !hasApiKey }, async () => { 179 const response = await callClaude({ 180 prompt: 'Say "test" and nothing else.', 181 model: 'anthropic/claude-3.5-sonnet', 182 maxTokens: 20, 183 }); 184 185 assert.ok(response); 186 assert.ok(typeof response === 'string'); 187 }); 188 189 test('callClaude - accepts system prompt', { skip: !hasApiKey }, async () => { 190 const response = await callClaude({ 191 prompt: 'What is your role?', 192 systemPrompt: 'You are a security expert. Always respond with security focus.', 193 maxTokens: 50, 194 }); 195 196 assert.ok(response); 197 assert.ok(typeof response === 'string'); 198 }); 199 200 test('analyzeCodeSecurity - handles safe code', { skip: !hasApiKey }, async () => { 201 const safeCode = ` 202 import Database from 'better-sqlite3'; 203 const db = new Database('data.db'); 204 const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); 205 const result = stmt.get(userId); 206 `; 207 208 const analysis = await analyzeCodeSecurity(safeCode, 'sql_injection', 'safe.js'); 209 210 assert.ok(analysis.findings); 211 assert.ok(Array.isArray(analysis.findings)); 212 // Safe code should have minimal or no findings 213 }); 214 215 test('generateSecureFix - command injection fix', { skip: !hasApiKey }, async () => { 216 const vulnerableCode = `const output = execSync(\`ls -la \${userInput}\`);`; 217 218 const finding = { 219 type: 'command_injection', 220 severity: 'high', 221 line: 1, 222 description: 'Command injection via string interpolation', 223 recommendation: 'Use spawn() with array arguments', 224 }; 225 226 const fix = await generateSecureFix({ 227 code: vulnerableCode, 228 finding, 229 fileName: 'commands.js', 230 }); 231 232 assert.ok(fix.old_string); 233 assert.ok(fix.new_string); 234 assert.ok(fix.explanation); 235 236 // Fix should recommend spawn or input sanitization 237 assert.ok( 238 fix.new_string.includes('spawn') || 239 fix.explanation.toLowerCase().includes('spawn') || 240 fix.explanation.toLowerCase().includes('sanitize') 241 ); 242 }); 243 244 test('performThreatModeling - file upload threats', { skip: !hasApiKey }, async () => { 245 const component = ` 246 app.post('/upload', (req, res) => { 247 const { filename, content } = req.body; 248 249 // Path traversal vulnerability 250 const filepath = \`./uploads/\${filename}\`; 251 fs.writeFileSync(filepath, content); 252 253 // No file type validation 254 res.json({ success: true, path: filepath }); 255 }); 256 `; 257 258 const result = await performThreatModeling({ 259 component, 260 componentType: 'file_upload', 261 dataFlow: 'Client → Upload endpoint → File system', 262 }); 263 264 assert.ok(result.threats.length > 0, 'Should identify file upload threats'); 265 266 // Should identify path traversal or file upload risks 267 const threatTypes = result.threats.map(t => t.stride_category); 268 assert.ok( 269 threatTypes.includes('Tampering') || 270 threatTypes.includes('ElevationOfPrivilege') || 271 threatTypes.includes('InformationDisclosure') 272 ); 273 }); 274 275 test('analyzeCodeSecurity - XSS detection', { skip: !hasApiKey }, async () => { 276 const vulnerableCode = ` 277 app.get('/profile', (req, res) => { 278 const username = req.query.name; 279 res.send(\`<h1>Welcome \${username}</h1>\`); 280 }); 281 `; 282 283 const analysis = await analyzeCodeSecurity(vulnerableCode, 'xss', 'profile.js'); 284 285 assert.ok(analysis.findings); 286 287 if (analysis.findings.length > 0) { 288 // Should detect XSS or HTML injection 289 assert.ok( 290 analysis.findings.some( 291 f => 292 f.description.toLowerCase().includes('xss') || 293 f.description.toLowerCase().includes('injection') || 294 f.description.toLowerCase().includes('escape') 295 ) 296 ); 297 } 298 }); 299 300 test( 301 'performThreatModeling - identifies multiple STRIDE categories', 302 { 303 skip: !hasApiKey, 304 }, 305 async () => { 306 const component = ` 307 // Complex vulnerable component 308 app.post('/admin/users', (req, res) => { 309 // No authentication check (ElevationOfPrivilege) 310 const { username, role } = req.body; 311 312 // SQL injection (Tampering) 313 db.exec(\`INSERT INTO users (username, role) VALUES ('\${username}', '\${role}')\`); 314 315 // No logging (Repudiation) 316 res.json({ success: true }); 317 }); 318 `; 319 320 const result = await performThreatModeling({ 321 component, 322 componentType: 'admin_api', 323 }); 324 325 assert.ok(result.threats.length > 0); 326 327 const strideCategories = [...new Set(result.threats.map(t => t.stride_category))]; 328 329 // Should identify multiple different threat types 330 assert.ok( 331 strideCategories.length >= 2, 332 `Should identify multiple STRIDE categories, found: ${strideCategories.join(', ')}` 333 ); 334 } 335 ); 336 337 test('generateSecureFix - preserves code style', { skip: !hasApiKey }, async () => { 338 const vulnerableCode = ` 339 function getUser(id) { 340 const query = \`SELECT * FROM users WHERE id = \${id}\`; 341 return db.exec(query); 342 } 343 `; 344 345 const finding = { 346 type: 'sql_injection', 347 severity: 'critical', 348 line: 2, 349 description: 'SQL injection', 350 recommendation: 'Use prepared statements', 351 }; 352 353 const fix = await generateSecureFix({ 354 code: vulnerableCode, 355 finding, 356 fileName: 'users.js', 357 }); 358 359 assert.ok(fix.old_string); 360 assert.ok(fix.new_string); 361 362 // Should preserve function structure 363 assert.ok(fix.new_string.includes('function') || fix.old_string.includes('function')); 364 });