Initial Playwright ERP UAT VAPT test suite v2.4.1 IMAP
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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}`);
|
||||
})();
|
||||
Reference in New Issue
Block a user