Files
2026-06-26 20:38:14 +05:30

159 lines
8.4 KiB
JavaScript

// 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, absoluteUrl } = require('./fixtures/url');
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}`);
})();