// Standalone runner: executes the API-level (no-browser) security checks // from the v2.0.4 suite against a live ERP. Produces JSON results. const { request } = require('@playwright/test'); const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:8000'; const results = []; function record(id, title, status, detail) { results.push({ id, title, status, detail }); const tag = status === 'PASS' ? 'PASS' : status === 'SKIP' ? 'SKIP' : 'FAIL'; console.log(`[${tag}] ${id} ${title}${detail ? ' -- ' + detail : ''}`); } (async () => { const api = await request.newContext({ baseURL: BASE_URL }); // V204-SEC-001: forgot-password must not leak a reset token try { const email = process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local'; const candidates = [ { url: '/api/auth/forgot-password', opts: { data: { email } } }, { url: '/api/auth/forgot-password', opts: { form: { email } } }, { url: '/forgot-password', opts: { form: { email } } }, ]; let checked = false, leaked = false, serverErr = false; for (const c of candidates) { const resp = await api.post(c.url, c.opts).catch(() => null); if (!resp) continue; if ([404, 405].includes(resp.status())) continue; checked = true; if (resp.status() >= 500) serverErr = true; const text = await resp.text(); if (/reset_token|password_reset_token|invite_token/i.test(text)) leaked = true; if (/[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}/.test(text)) leaked = true; } if (!checked) record('V204-SEC-001', 'forgot-password token leak', 'SKIP', 'no forgot-password route responded'); else if (serverErr) record('V204-SEC-001', 'forgot-password token leak', 'FAIL', '500 from endpoint'); else if (leaked) record('V204-SEC-001', 'forgot-password token leak', 'FAIL', 'token-like value in response body'); else record('V204-SEC-001', 'forgot-password token leak', 'PASS', 'no token leaked'); } catch (e) { record('V204-SEC-001', 'forgot-password token leak', 'FAIL', String(e)); } // V204-SEC-002: reset-password with bogus token rejected safely try { const resp = await api.post('/api/auth/reset-password', { data: { token: 'invalid-token-for-vapt', password: 'NewPassword@123' }, }).catch(() => null); if (!resp || [404, 405].includes(resp.status())) record('V204-SEC-002', 'reset-password bogus token', 'SKIP', 'route not available'); else if (resp.status() >= 500) record('V204-SEC-002', 'reset-password bogus token', 'FAIL', `status ${resp.status()}`); else if ([400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status())) record('V204-SEC-002', 'reset-password bogus token', 'PASS', `safely rejected (${resp.status()})`); else record('V204-SEC-002', 'reset-password bogus token', 'FAIL', `unexpected status ${resp.status()}`); } catch (e) { record('V204-SEC-002', 'reset-password bogus token', 'FAIL', String(e)); } // V204-SEC-003: brute-force login attempts don't 500 try { const email = process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local'; let skip = false, bad = null; for (let i = 0; i < 6; i++) { const resp = await api.post('/api/auth/token', { form: { username: email, email, password: `wrong-password-${i}` }, }).catch(() => null); if (!resp || [404, 405].includes(resp.status())) { skip = true; break; } if (resp.status() >= 500) { bad = resp.status(); break; } if (![400, 401, 403, 422, 429].includes(resp.status())) { bad = resp.status(); break; } } if (skip) record('V204-SEC-003', 'login brute-force no 500', 'SKIP', '/api/auth/token not available'); else if (bad) record('V204-SEC-003', 'login brute-force no 500', 'FAIL', `status ${bad}`); else record('V204-SEC-003', 'login brute-force no 500', 'PASS', 'all attempts safely handled'); } catch (e) { record('V204-SEC-003', 'login brute-force no 500', 'FAIL', String(e)); } // V204-STORAGE-001: storage-agent endpoints require node auth try { const endpoints = [ '/documents/storage-agent/jobs/pending', '/documents/storage-agent/download-requests/pending', ]; let bad = null, anySeen = false; for (const url of endpoints) { const resp = await api.get(url).catch(() => null); if (!resp) continue; anySeen = true; if (resp.status() >= 500) { bad = `${url} -> ${resp.status()}`; break; } if (![400, 401, 403, 404, 405, 422].includes(resp.status())) { bad = `${url} -> ${resp.status()} (unauth access?)`; break; } } if (!anySeen) record('V204-STORAGE-001', 'storage-agent requires auth', 'SKIP', 'no endpoint responded'); else if (bad) record('V204-STORAGE-001', 'storage-agent requires auth', 'FAIL', bad); else record('V204-STORAGE-001', 'storage-agent requires auth', 'PASS', 'endpoints require authentication'); } catch (e) { record('V204-STORAGE-001', 'storage-agent requires auth', 'FAIL', String(e)); } // V204-BACKUP-001: backup screens not accessible anonymously try { const paths = [ '/system-settings/financial-years', '/system-settings/year-backup', '/system-settings/year-backups', ]; let bad = null, anySeen = false; for (const p of paths) { const resp = await api.get(p, { maxRedirects: 0 }).catch(() => null); if (!resp) continue; anySeen = true; const st = resp.status(); // anonymous: must redirect to login (3xx) or be denied (401/403/404). 200 = leak. if (st === 200) { bad = `${p} returned 200 anonymously`; break; } if (st >= 500) { bad = `${p} -> ${st}`; break; } } if (!anySeen) record('V204-BACKUP-001', 'backup screens not anon-accessible', 'SKIP', 'no route responded'); else if (bad) record('V204-BACKUP-001', 'backup screens not anon-accessible', 'FAIL', bad); else record('V204-BACKUP-001', 'backup screens not anon-accessible', 'PASS', 'anonymous access blocked/redirected'); } catch (e) { record('V204-BACKUP-001', 'backup screens not anon-accessible', 'FAIL', String(e)); } // V204-HEADERS-001 (partial): security headers on /login try { const resp = await api.get('/login').catch(() => null); if (!resp) record('V204-HEADERS-001', 'security headers on /login', 'SKIP', '/login no response'); else { const h = resp.headers(); const missing = []; if (!h['x-content-type-options']) missing.push('X-Content-Type-Options'); if (!h['x-frame-options'] && !h['content-security-policy']) missing.push('X-Frame-Options/CSP'); if (missing.length) record('V204-HEADERS-001', 'security headers on /login', 'FAIL', 'missing: ' + missing.join(', ')); else record('V204-HEADERS-001', 'security headers on /login', 'PASS', 'core security headers present'); } } catch (e) { record('V204-HEADERS-001', 'security headers on /login', 'FAIL', String(e)); } // VAPT: unauthenticated access to a protected page redirects to login try { const resp = await api.get('/system-settings/users', { maxRedirects: 0 }).catch(() => null); if (!resp) record('VAPT-AUTH-001', 'protected route requires auth', 'SKIP', 'no response'); else { const st = resp.status(); if (st === 200) record('VAPT-AUTH-001', 'protected route requires auth', 'FAIL', 'users page served without auth'); else if (st >= 500) record('VAPT-AUTH-001', 'protected route requires auth', 'FAIL', `status ${st}`); else record('VAPT-AUTH-001', 'protected route requires auth', 'PASS', `blocked/redirected (${st})`); } } catch (e) { record('VAPT-AUTH-001', 'protected route requires auth', 'FAIL', String(e)); } await api.dispose(); const summary = { pass: results.filter(r => r.status === 'PASS').length, fail: results.filter(r => r.status === 'FAIL').length, skip: results.filter(r => r.status === 'SKIP').length, }; console.log(`\nSUMMARY: ${summary.pass} passed, ${summary.fail} failed, ${summary.skip} skipped`); // Write results to a portable path instead of a machine-specific /home/claude path. // Override with API_CHECK_RESULTS_FILE if needed, otherwise defaults to results/api-check-results.json. const fs = require('fs'); const path = require('path'); const outputFile = process.env.API_CHECK_RESULTS_FILE || path.join('results', 'api-check-results.json'); const outputDir = path.dirname(outputFile); fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync(outputFile, JSON.stringify({ summary, results }, null, 2)); console.log(`Results written to ${outputFile}`); })();