159 lines
8.4 KiB
JavaScript
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}`);
|
|
})();
|