Files
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

222 lines
15 KiB
JavaScript

// Local-only observational audit. Product failures are recorded, not blessed as regression expectations.
// Run from the repository root after Release API and static UI builds (see verification.md).
// Browser requests are restricted to loopback; integrations exercised here use local synthetic fixtures.
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const root = process.cwd();
const ui = path.join(root, 'job-tracker-ui');
const require = createRequire(path.join(ui, 'package.json'));
const { chromium, request } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
const evidence = path.join(root, '.artifacts', 'expanded-audit');
await mkdir(evidence, { recursive: true });
const dataRoot = await mkdtemp(path.join(os.tmpdir(), 'jobjakt-audit-'));
const apiOrigin = 'http://localhost:5402';
const uiOrigin = 'http://localhost:3400';
const password = 'SyntheticAudit123!';
const findings = [];
const consoleErrors = [];
const networkErrors = [];
const timings = [];
const blockedExternalHosts = new Set();
const children = [];
let browser;
const record = (scenario, meetsExpectation, observation) => findings.push({ scenario, meetsExpectation, observation });
async function waitReady(url) {
for (let i = 0; i < 120; i++) {
try { if ((await fetch(url, { signal: AbortSignal.timeout(1000) })).ok) return; } catch {}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error(`Local audit server did not start: ${url}`);
}
function start(command, args, cwd, env, logName) {
const child = spawn(command, args, { cwd, env: { ...process.env, ...env }, windowsHide: true });
const log = createWriteStream(path.join(evidence, logName));
child.stdout.pipe(log); child.stderr.pipe(log);
children.push(child);
return child;
}
async function stop(child) {
if (child.exitCode !== null) return;
if (process.platform === 'win32') {
await new Promise(resolve => {
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
killer.on('exit', resolve);
});
} else child.kill('SIGTERM');
}
async function client(email) {
const client = await request.newContext({ baseURL: apiOrigin });
const login = await client.post('/api/auth/login', { data: { email, password } });
if (!login.ok()) throw new Error(`Synthetic login failed: ${login.status()}`);
const cookie = (await client.storageState()).cookies.find(c => c.name === 'XSRF-TOKEN');
return { client, headers: { 'X-CSRF-TOKEN': cookie.value } };
}
async function body(response) {
if (!response.ok()) throw new Error(`Fixture request failed: ${response.status()} ${response.url()}`);
return response.json();
}
try {
// Override outbound integrations and storage even if the developer shell has provider settings.
start(process.env.DOTNET_HOST_PATH || path.join(os.homedir(), '.dotnet', 'dotnet.exe'),
[path.join(root, 'JobTrackerApi/bin/Release/net9.0/JobTrackerApi.dll'), '--urls', apiOrigin],
path.join(root, 'JobTrackerApi'), {
ASPNETCORE_ENVIRONMENT: 'Development', DOTNET_CLI_TELEMETRY_OPTOUT: '1',
Data__Root: dataRoot, Database__Provider: 'sqlite', ConnectionStrings__JobTracker: `Data Source=${path.join(dataRoot, 'jobtracker.db')}`,
AccountLifecycle__TombstonesRoot: path.join(dataRoot, 'tombstones'),
Auth__Require: 'true', Auth__JwtKey: 'audit-synthetic-signing-key-only-32-chars-minimum',
Auth__AdminEmail: 'audit-admin@example.test', Auth__AdminPassword: password,
Auth__TurnstileSecretKey: '', Auth__GoogleClientId: '', Auth__MicrosoftClientId: '',
Auth__AllowRegistration: 'false', Auth__MicrosoftTenant: 'common',
App__PublicBaseUrl: uiOrigin, Cors__Origins__0: uiOrigin, HttpsRedirection__Enabled: 'false',
Ai__BaseUrl: 'http://127.0.0.1:1', Email__Enabled: 'false', Stripe__SecretKey: '',
Workers__RulesEnabled: 'false', Workers__FollowUpRemindersEnabled: 'false',
Workers__DailyExportEnabled: 'false', Workers__JobEnrichmentEnabled: 'false',
Backups__Enabled: 'false', CV_PDF_BROWSER_PATH: chromium.executablePath(),
}, 'break-api.log');
await waitReady(`${apiOrigin}/health`);
const admin = await client('audit-admin@example.test');
for (const email of ['audit-a@example.test', 'audit-b@example.test']) {
await body(await admin.client.post('/api/users', { headers: admin.headers, data: { email, password, roles: [] } }));
}
const a = await client('audit-a@example.test');
const b = await client('audit-b@example.test');
await body(await a.client.put('/api/career/profile', { headers: a.headers, data: { profile: {
version: '1', contact: { fullName: 'Åse Audit 🧪', email: 'audit-a@example.test', headline: 'Software developer' },
summary: ['Synthetic profile for local audit only.'],
jobs: [{ company: 'Fjord Example', title: 'Developer', start: '2021', end: '2025', bullets: ['Built reliable APIs.'] }],
skills: ['C#', 'TypeScript', 'Norsk'],
} } }));
const cv = await body(await a.client.post('/api/cv/variants', { headers: a.headers, data: { name: 'Audit CV' } }));
const stale = await body(await a.client.get(`/api/cv/variants/${cv.id}`));
const first = await a.client.put(`/api/cv/variants/${cv.id}`, { headers: a.headers, data: { name: 'Newer edit', settings: cv.settings, version: cv.version } });
const second = await a.client.put(`/api/cv/variants/${cv.id}`, { headers: a.headers, data: { name: 'Stale tab edit', settings: stale.settings, version: stale.version } });
const final = await body(await a.client.get(`/api/cv/variants/${cv.id}`));
record('stale CV save', second.status() === 409 || second.status() === 412, { first: first.status(), staleSave: second.status(), overwritten: final.name === 'Stale tab edit', version: final.version });
const concurrent = await Promise.all(['Concurrent A', 'Concurrent B'].map(name => a.client.put(`/api/cv/variants/${cv.id}`, { headers: a.headers, data: { name, settings: cv.settings } })));
record('simultaneous CV saves detect conflict', concurrent.some(r => [409, 412].includes(r.status())), { statuses: concurrent.map(r => r.status()) });
for (const method of ['get', 'put', 'delete']) {
const response = await b.client[method](`/api/cv/variants/${cv.id}`, { headers: b.headers, ...(method === 'put' ? { data: { name: 'Cross-owner', settings: cv.settings } } : {}) });
record(`CV cross-owner ${method}`, [403, 404].includes(response.status()), { status: response.status() });
}
const company = await body(await a.client.post('/api/companies', { headers: a.headers, data: { name: 'Åse & Sons 🧪', location: 'Oslo' } }));
const input = { companyId: company.id, jobTitle: '=1+1', status: 'Saved', notes: '<img src=x onerror=alert(1)>' };
const created = await Promise.all([0, 1].map(() => a.client.post('/api/jobapplications', { headers: a.headers, data: input })));
const jobs = await Promise.all(created.map(body));
record('duplicate job creation deduplicated', jobs[0].id === jobs[1].id, { statuses: created.map(r => r.status()), distinctIds: jobs[0].id !== jobs[1].id });
for (const [stamp, expectedUtc] of [
['2026-03-28T09:00:00+01:00', '20260328T080000Z'],
['2026-03-29T09:00:00+02:00', '20260329T070000Z'],
['2026-10-25T02:30:00+02:00', '20261025T003000Z'],
['2026-10-25T02:30:00+01:00', '20261025T013000Z'],
]) {
const dated = await body(await a.client.post('/api/jobapplications', { headers: a.headers, data: { companyId: company.id, jobTitle: 'Synthetic DST probe', status: 'Saved', followUpAt: stamp } }));
const ics = await a.client.get(`/api/calendar/jobs/${dated.id}/events.ics?kind=follow-up`);
const content = await ics.text();
const actual = content.match(/^DTSTART:(.+)$/m)?.[1]?.trim();
record(`DST calendar instant ${stamp}`, actual === expectedUtc, { status: ics.status(), expectedUtc, actual });
}
const csv = await a.client.get('/api/export/jobs?format=csv');
const csvText = await csv.text();
record('CSV formula neutralized', !csvText.includes(',=1+1,'), { formulaCellPresent: csvText.includes(',=1+1,') });
for (const route of ['/api/auth/me', `/api/cv/variants/${cv.id}`, '/api/export/jobs?format=csv']) {
const response = await a.client.get(route);
record(`sensitive response cache ${route.split('?')[0]}`, /no-store/i.test(response.headers()['cache-control'] || ''), { cacheControl: response.headers()['cache-control'] || null });
}
const adminAccess = await b.client.get('/api/admin/audit');
record('standard user admin denial', adminAccess.status() === 403, { status: adminAccess.status() });
for (let i = 0; i < 2; i++) await a.client.post('/api/export/account', { headers: a.headers, data: {} });
const otherExport = await b.client.post('/api/export/account', { headers: b.headers, data: {} });
record('account export limiter isolation', otherExport.status() === 200, { otherAccountStatusAfterTwoExports: otherExport.status() });
start(process.execPath, [path.join(ui, 'scripts/serve-export.mjs')], ui,
{ PORT: '3400' }, 'break-ui.log');
await waitReady(uiOrigin);
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await context.route('**/*', route => {
const url = new URL(route.request().url());
if (['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'about:', 'blob:'].includes(url.protocol)) return route.continue();
blockedExternalHosts.add(url.hostname);
return route.abort();
});
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); });
const page = await context.newPage();
page.on('pageerror', error => consoleErrors.push(error.message));
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(message.text().slice(0, 500)); });
page.on('response', response => { if (response.status() >= 400) networkErrors.push({ path: new URL(response.url()).pathname, status: response.status() }); });
await page.goto(`${uiOrigin}/login`);
await page.getByLabel('Email').fill('audit-a@example.test');
await page.getByLabel('Current password').fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.waitForURL('**/dashboard');
for (const route of ['/jobs', '/career', `/career/builder/${cv.id}`]) {
const started = Date.now();
await page.goto(`${uiOrigin}${route}`);
await page.locator('main').waitFor();
timings.push({ route, navigationToMainMs: Date.now() - started });
}
await page.getByLabel('CV name', { exact: true }).waitFor();
for (const language of ['en', 'nb']) {
await a.client.put('/api/auth/preferences/language', { headers: a.headers, data: { language } });
for (const theme of ['light', 'dark']) {
await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, { language, theme });
await page.reload();
await page.locator('main').waitFor();
await page.waitForFunction(() => document.querySelector('[data-cv-page] iframe')?.getAttribute('srcdoc')?.includes('Synthetic profile'));
await page.waitForTimeout(400);
for (const width of [375, 1440]) {
await page.setViewportSize({ width, height: 900 });
await page.screenshot({ path: path.join(evidence, `builder-${language}-${theme}-${width}.png`), fullPage: true });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
record(`builder layout ${language}/${theme}/${width}`, overflow <= 1 && axe.violations.length === 0, { overflow,
actualLanguage: await page.locator('html').getAttribute('lang'), actualTheme: await page.locator('html').getAttribute('data-color-scheme'),
violations: axe.violations.map(v => ({ id: v.id, impact: v.impact, nodes: v.nodes.map(n => ({ target: n.target, failureSummary: n.failureSummary })) })) });
}
}
}
await a.client.put('/api/auth/preferences/language', { headers: a.headers, data: { language: 'en' } });
await page.evaluate(() => { localStorage.setItem('uiLanguage', 'en'); localStorage.setItem('jobtracker.themeMode', 'light'); });
await page.reload();
await page.getByLabel('CV name', { exact: true }).waitFor();
await context.setOffline(true);
await page.getByLabel('CV name', { exact: true }).fill('Offline synthetic draft');
await page.waitForTimeout(1600);
record('offline save reports failure', await page.getByText(/save failed|not saved|could not save/i).count() > 0, { matchingFailureMessages: await page.getByText(/save failed|not saved|could not save/i).count() });
await context.setOffline(false);
page.once('dialog', dialog => dialog.accept());
await page.reload();
const recoveredName = await page.getByLabel('CV name', { exact: true }).inputValue();
record('offline draft survives confirmed refresh', recoveredName === 'Offline synthetic draft', { draftRecovered: recoveredName === 'Offline synthetic draft' });
const secondTab = await context.newPage();
await secondTab.goto(`${uiOrigin}/career/builder/${cv.id}`);
await secondTab.getByLabel('CV name', { exact: true }).waitFor();
await secondTab.waitForTimeout(1000);
await page.evaluate(async () => {
await fetch('http://localhost:5402/api/auth/logout', { method: 'POST', credentials: 'include' });
localStorage.removeItem('authUserKey');
window.dispatchEvent(new Event('auth-changed'));
});
await secondTab.waitForTimeout(1000);
record('logout clears other tab immediately', !(await secondTab.getByLabel('CV name', { exact: true }).count()), { privateEditorStillMounted: (await secondTab.getByLabel('CV name', { exact: true }).count()) > 0 });
await context.close();
} catch (error) {
if (browser) for (const context of browser.contexts()) for (const page of context.pages()) {
await page.screenshot({ path: path.join(evidence, 'harness-failure.png'), fullPage: true }).catch(() => {});
}
findings.push({ scenario: 'harness', meetsExpectation: false, observation: String(error.stack || error) });
process.exitCode = 2;
} finally {
if (browser) await browser.close();
for (const child of children.reverse()) await stop(child);
await writeFile(path.join(evidence, 'break-results.json'), JSON.stringify({ generatedAt: new Date().toISOString(), scope: 'isolated SQLite and local Chromium; synthetic data; outbound browser requests blocked', findings, consoleErrors, networkErrors, blockedExternalHosts: [...blockedExternalHosts], timings }, null, 2));
console.log(JSON.stringify({ observations: findings.length, unmet: findings.filter(f => !f.meetsExpectation).length, evidence: '.artifacts/expanded-audit/break-results.json' }));
}