Some checks are pending
ci / verify (push) Waiting to run
Executors could not see the audit: Grok reported that the owner's AUDIT.md and evidence never reached it, so its scenarios could not be mapped to the audit IDs. Keeping the audit, the acceptance report and the task for each executor in the repository lets Codex, Antigravity and Grok take work and report through git. The live-check scripts are stored as .mjs.txt so the project linter, which type-checks every .mjs file, does not break for everyone; the maintained version of these checks is Grok's harness in tests/e2e. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
18 KiB
Text
426 lines
18 KiB
Text
// Live audit of krona-staff against a real PostgreSQL 17. Read-only for the repository:
|
||
// data is seeded in a throwaway local database, tokens are random and never printed.
|
||
import { createRequire } from 'node:module';
|
||
import { spawn } from 'node:child_process';
|
||
import { randomBytes, createECDH } from 'node:crypto';
|
||
import net from 'node:net';
|
||
|
||
const REPO = 'C:/Users/Ochenstarik/Agent_projects/krona-staff-audit';
|
||
const require = createRequire(`${REPO}/package.json`);
|
||
const pg = require('pg');
|
||
const { ulid } = require('ulid');
|
||
|
||
const SUPER = 'postgres://postgres:audit-superuser@127.0.0.1:55432/krona_staff';
|
||
const APP = 'postgres://krona_staff_app:audit-app-password@127.0.0.1:55432/krona_staff';
|
||
const PORT = 18080;
|
||
const BASE = `http://127.0.0.1:${PORT}/api/v1/staff`;
|
||
const TENANT = ulid();
|
||
const ACTOR = ulid();
|
||
const TERMINAL_ACTOR = ulid();
|
||
const MANAGER_TOKEN = randomBytes(32).toString('base64url');
|
||
const TERMINAL_TOKEN = randomBytes(32).toString('base64url');
|
||
const results = [];
|
||
|
||
function record(id, verdict, detail) {
|
||
results.push({ id, verdict, detail });
|
||
console.log(`[${id}] ${verdict} — ${detail}`);
|
||
}
|
||
|
||
async function superQuery(sql, params = []) {
|
||
const client = new pg.Client({ connectionString: SUPER });
|
||
await client.connect();
|
||
try {
|
||
return await client.query(sql, params);
|
||
} finally {
|
||
await client.end();
|
||
}
|
||
}
|
||
|
||
function startServer(extraEnv) {
|
||
const env = {
|
||
...process.env,
|
||
NODE_ENV: 'development',
|
||
HOST: '127.0.0.1',
|
||
PORT: String(PORT),
|
||
LOG_LEVEL: 'silent',
|
||
STAFF_RUNTIME_MODE: 'standalone',
|
||
DATABASE_URL: APP,
|
||
STANDALONE_TENANT_ID: TENANT,
|
||
STANDALONE_ACTOR_ID: ACTOR,
|
||
STANDALONE_TERMINAL_ACTOR_ID: TERMINAL_ACTOR,
|
||
...extraEnv,
|
||
};
|
||
for (const [key, value] of Object.entries(env)) if (value === undefined) delete env[key];
|
||
const child = spawn(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'src/server.ts'], {
|
||
cwd: REPO,
|
||
env,
|
||
stdio: ['ignore', 'ignore', 'pipe'],
|
||
});
|
||
let stderr = '';
|
||
child.stderr.on('data', (chunk) => (stderr += chunk));
|
||
child.stderrText = () => stderr;
|
||
return child;
|
||
}
|
||
|
||
async function waitReady() {
|
||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||
try {
|
||
const response = await fetch(`${BASE}/live`);
|
||
if (response.ok) return;
|
||
} catch {}
|
||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||
}
|
||
throw new Error('server did not start');
|
||
}
|
||
|
||
async function stop(child) {
|
||
child.kill();
|
||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||
}
|
||
|
||
async function call(method, path, { token, body, headers = {} } = {}) {
|
||
const response = await fetch(`${BASE}${path}`, {
|
||
method,
|
||
headers: {
|
||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||
...headers,
|
||
},
|
||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||
});
|
||
const text = await response.text();
|
||
let json;
|
||
try {
|
||
json = JSON.parse(text);
|
||
} catch {
|
||
json = text;
|
||
}
|
||
return { status: response.status, json, headers: response.headers };
|
||
}
|
||
|
||
function brief(value) {
|
||
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||
return text.length > 220 ? `${text.slice(0, 220)}…` : text;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- scenario A
|
||
async function scenarioOpenMode(access) {
|
||
const server = startServer({ STANDALONE_ACCESS: access });
|
||
try {
|
||
await waitReady();
|
||
const portal = await call('GET', '/portal');
|
||
const payroll = await call('GET', '/payroll');
|
||
const finance = await call('GET', '/finance');
|
||
const writeProfession = await call('POST', '/professions', {
|
||
body: { name: `audit-open-${access}-${Date.now()}` },
|
||
});
|
||
const session = await call('POST', '/session', {
|
||
body: { access_token: 'x'.repeat(40) },
|
||
headers: { origin: `http://127.0.0.1:${PORT}` },
|
||
});
|
||
record(
|
||
'F2',
|
||
portal.status === 200 || payroll.status === 200 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||
`STANDALONE_ACCESS=${access}, без токенов и без Authorization: GET /portal ${portal.status}, GET /payroll ${payroll.status}, GET /finance ${finance.status}, POST /professions ${writeProfession.status}; POST /session ${session.status}`,
|
||
);
|
||
} finally {
|
||
await stop(server);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- scenario B
|
||
async function seed() {
|
||
const orgUnit = ulid();
|
||
const employee = ulid();
|
||
const pushEmployee = ulid();
|
||
const session = ulid();
|
||
await superQuery(
|
||
`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'audit-unit', 'Аудиторская точка')`,
|
||
[orgUnit, TENANT],
|
||
);
|
||
await superQuery(
|
||
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status)
|
||
VALUES ($1, $3, 'A-001', 'Аудит Сотрудник', 'active'), ($2, $3, 'A-002', 'Аудит Push', 'active')`,
|
||
[employee, pushEmployee, TENANT],
|
||
);
|
||
await superQuery(
|
||
`INSERT INTO staff_work_sessions
|
||
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status)
|
||
VALUES ($1, $2, $3, $4, '2026-09-10', '2026-09-10T02:00:00Z', '2026-09-10T10:00:00Z', 480, 'pending_review')`,
|
||
[session, TENANT, employee, orgUnit],
|
||
);
|
||
return { orgUnit, employee, pushEmployee, session };
|
||
}
|
||
|
||
async function scenarioWithTokens() {
|
||
const ids = await seed();
|
||
const server = startServer({
|
||
STANDALONE_ACCESS: 'employee',
|
||
STANDALONE_MANAGER_TOKEN: MANAGER_TOKEN,
|
||
STANDALONE_TERMINAL_TOKEN: TERMINAL_TOKEN,
|
||
});
|
||
try {
|
||
await waitReady();
|
||
const origin = { origin: `http://127.0.0.1:${PORT}` };
|
||
|
||
// F4: brute force on /session
|
||
const statuses = {};
|
||
for (let index = 0; index < 60; index += 1) {
|
||
const response = await call('POST', '/session', {
|
||
body: { access_token: randomBytes(32).toString('base64url') },
|
||
});
|
||
statuses[response.status] = (statuses[response.status] ?? 0) + 1;
|
||
}
|
||
record(
|
||
'F4',
|
||
statuses[429] ? 'не подтверждено' : 'ПОДТВЕРЖДЕНО',
|
||
`60 неверных POST /session → ${JSON.stringify(statuses)}`,
|
||
);
|
||
|
||
// F3: cookie carries the raw token and survives logout
|
||
const login = await call('POST', '/session', { body: { access_token: MANAGER_TOKEN } });
|
||
const setCookie = login.headers.get('set-cookie') ?? '';
|
||
const cookieValue = decodeURIComponent(setCookie.split(';')[0].split('=').slice(1).join('='));
|
||
const cookieIsToken = cookieValue === MANAGER_TOKEN;
|
||
const cookieHeader = setCookie.split(';')[0];
|
||
const logout = await call('DELETE', '/session', { headers: { cookie: cookieHeader } });
|
||
const afterLogout = await call('GET', '/portal', { headers: { cookie: cookieHeader } });
|
||
record(
|
||
'F3',
|
||
cookieIsToken && afterLogout.status === 200 ? 'ПОДТВЕРЖДЕНО' : 'частично',
|
||
`вход ${login.status}, выход ${logout.status}; cookie содержит сам токен менеджера: ${cookieIsToken}; после DELETE /session старая cookie → GET /portal ${afterLogout.status}; флаги: ${setCookie.split(';').slice(1).map((part) => part.trim()).join('; ')}`,
|
||
);
|
||
|
||
// N1: create employee
|
||
const created = await call('POST', '/employees', {
|
||
token: MANAGER_TOKEN,
|
||
body: { personnel_number: 'A-100', display_name: 'Новый Сотрудник', position: 'Повар' },
|
||
});
|
||
record(
|
||
'N1',
|
||
created.status >= 500 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||
`POST /employees → ${created.status} ${brief(created.json)}`,
|
||
);
|
||
|
||
// N2: confirm session and calculate
|
||
const confirm = await call('PATCH', `/work-sessions/${ids.session}`, {
|
||
token: MANAGER_TOKEN,
|
||
body: { status: 'confirmed' },
|
||
});
|
||
const rules = await call('GET', '/pay-rules', { token: MANAGER_TOKEN });
|
||
const ruleList = rules.json?.pay_rules ?? [];
|
||
const hourlyRule =
|
||
ruleList.find((rule) => /hour/.test(String(rule.code))) ?? ruleList[0];
|
||
const assignment = hourlyRule
|
||
? await call('PUT', `/employees/${ids.employee}/pay-assignment`, {
|
||
token: MANAGER_TOKEN,
|
||
body: {
|
||
pay_rule_id: hourlyRule.id,
|
||
org_unit_id: ids.orgUnit,
|
||
rate_minor: 30000,
|
||
effective_from: '2026-09-01',
|
||
},
|
||
})
|
||
: { status: 'нет правил', json: rules.json };
|
||
const calculate = await call('POST', '/payroll/calculate', {
|
||
token: MANAGER_TOKEN,
|
||
body: { month: '2026-09' },
|
||
});
|
||
record(
|
||
'N2',
|
||
confirm.status >= 500 || calculate.status >= 500 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||
`PATCH /work-sessions (confirmed) → ${confirm.status} ${brief(confirm.json)}; GET /pay-rules → ${rules.status} (${ruleList.length} правил, выбрано ${hourlyRule?.code}); PUT pay-assignment → ${assignment.status}; POST /payroll/calculate → ${calculate.status} ${brief(calculate.json)}`,
|
||
);
|
||
|
||
// N4: overlapping runs, after bypassing N2 in this throwaway database only
|
||
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()');
|
||
try {
|
||
const confirmAgain = await call('PATCH', `/work-sessions/${ids.session}`, {
|
||
token: MANAGER_TOKEN,
|
||
body: { status: 'confirmed' },
|
||
});
|
||
const periods = [
|
||
{ month: '2026-09' },
|
||
{ from: '2026-09-02', to: '2026-09-30' },
|
||
{ month: '2026-09', org_unit_id: ids.orgUnit },
|
||
];
|
||
const steps = [];
|
||
for (const period of periods) {
|
||
const run = await call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: period });
|
||
const runId = run.json?.run_id ?? run.json?.id ?? run.json?.run?.id;
|
||
const approve = runId
|
||
? await call('POST', `/payroll/runs/${runId}/approve`, { token: MANAGER_TOKEN })
|
||
: { status: 'нет run_id', json: run.json };
|
||
steps.push(`${JSON.stringify(period)}: calculate ${run.status}, approve ${approve.status}`);
|
||
}
|
||
const ledger = await superQuery(
|
||
`SELECT count(*)::int AS entries, COALESCE(sum(amount_minor), 0)::bigint AS total
|
||
FROM staff_payroll_ledger WHERE tenant_id = $1 AND employee_id = $2 AND kind = 'accrual'`,
|
||
[TENANT, ids.employee],
|
||
);
|
||
const { entries, total } = ledger.rows[0];
|
||
record(
|
||
'N4',
|
||
entries > 1 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||
`повторное подтверждение смены → ${confirmAgain.status}; ${steps.join('; ')}; в реестре начислений за одну смену 8 ч × 300 ₽: записей ${entries}, сумма ${Number(total) / 100} ₽`,
|
||
);
|
||
|
||
// D5: a session already paid by an approved run can still be rejected or edited
|
||
const rejectPaid = await call('PATCH', `/work-sessions/${ids.session}`, {
|
||
token: MANAGER_TOKEN,
|
||
body: { status: 'rejected', correction_note: 'audit: after approval' },
|
||
});
|
||
const ledgerAfter = await superQuery(
|
||
`SELECT count(*)::int AS entries FROM staff_payroll_ledger
|
||
WHERE tenant_id = $1 AND employee_id = $2`,
|
||
[TENANT, ids.employee],
|
||
);
|
||
record(
|
||
'D5',
|
||
rejectPaid.status < 300 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||
`отклонение смены, уже начисленной в утверждённой ведомости → ${rejectPaid.status}; записей в реестре после этого: ${ledgerAfter.rows[0].entries} (сторно не создано)`,
|
||
);
|
||
|
||
// N13: two first calculations of the same new period at once
|
||
await superQuery(
|
||
`INSERT INTO staff_work_sessions
|
||
(id, tenant_id, employee_id, org_unit_id, business_date, worked_minutes, status)
|
||
VALUES ($1, $2, $3, $4, '2026-10-05', 480, 'confirmed')`,
|
||
[ulid(), TENANT, ids.employee, ids.orgUnit],
|
||
);
|
||
const race = await Promise.all([
|
||
call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-10' } }),
|
||
call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-10' } }),
|
||
]);
|
||
record(
|
||
'N13',
|
||
race.some((response) => response.status >= 500) ? 'ПОДТВЕРЖДЕНО' : 'не воспроизведено',
|
||
`два одновременных первых расчёта октября → ${race.map((response) => `${response.status} ${brief(response.json).slice(0, 90)}`).join(' | ')}`,
|
||
);
|
||
|
||
// Validation holes: impossible month and effective date
|
||
const badMonth = await call('POST', '/payroll/calculate', {
|
||
token: MANAGER_TOKEN,
|
||
body: { month: '2026-13' },
|
||
});
|
||
const hugeRate = await call('PUT', `/employees/${ids.employee}/pay-assignment`, {
|
||
token: MANAGER_TOKEN,
|
||
body: {
|
||
pay_rule_id: hourlyRule?.id,
|
||
org_unit_id: ids.orgUnit,
|
||
rate_minor: 1e20,
|
||
effective_from: '2026-11-01',
|
||
unexpected_field: 'accepted?',
|
||
},
|
||
});
|
||
record(
|
||
'D9',
|
||
'проверка',
|
||
`month=2026-13 → ${badMonth.status}; rate_minor=1e20 и лишнее поле → ${hugeRate.status} ${brief(hugeRate.json).slice(0, 120)}`,
|
||
);
|
||
} finally {
|
||
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at DROP DEFAULT');
|
||
}
|
||
|
||
// N3: SSRF through a push subscription
|
||
const inbound = { connections: 0, tlsHello: false };
|
||
const listener = net.createServer((socket) => {
|
||
inbound.connections += 1;
|
||
socket.once('data', (chunk) => {
|
||
if (chunk[0] === 0x16) inbound.tlsHello = true;
|
||
socket.destroy();
|
||
});
|
||
});
|
||
await new Promise((resolve) => listener.listen(18443, '127.0.0.1', resolve));
|
||
try {
|
||
const issued = await call('POST', `/employees/${ids.pushEmployee}/access-token`, {
|
||
token: MANAGER_TOKEN,
|
||
});
|
||
const employeeToken = issued.json?.access_token;
|
||
const ecdh = createECDH('prime256v1');
|
||
ecdh.generateKeys();
|
||
const subscribe = await call('POST', '/push/subscriptions', {
|
||
token: employeeToken,
|
||
body: {
|
||
endpoint: 'https://127.0.0.1:18443/internal-admin',
|
||
keys: {
|
||
p256dh: ecdh.getPublicKey().toString('base64url'),
|
||
auth: randomBytes(16).toString('base64url'),
|
||
},
|
||
},
|
||
});
|
||
const clock = await call('POST', '/attendance/events', {
|
||
token: TERMINAL_TOKEN,
|
||
body: {
|
||
employee_id: ids.pushEmployee,
|
||
event_type: 'check_in',
|
||
occurred_at: new Date().toISOString(),
|
||
source_event_id: `audit-${Date.now()}`,
|
||
org_unit_id: ids.orgUnit,
|
||
},
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||
record(
|
||
'N3',
|
||
inbound.connections > 0 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||
`выдача ключа сотруднику → ${issued.status}; подписка на https://127.0.0.1:18443 → ${subscribe.status}; отметка терминала → ${clock.status}; входящих соединений на внутренний адрес: ${inbound.connections}, TLS ClientHello: ${inbound.tlsHello}`,
|
||
);
|
||
|
||
// N9: another employee re-binds the same endpoint
|
||
const other = await call('POST', `/employees/${ids.employee}/access-token`, {
|
||
token: MANAGER_TOKEN,
|
||
});
|
||
const rebind = await call('POST', '/push/subscriptions', {
|
||
token: other.json?.access_token,
|
||
body: {
|
||
endpoint: 'https://127.0.0.1:18443/internal-admin',
|
||
keys: { p256dh: ecdh.getPublicKey().toString('base64url'), auth: randomBytes(16).toString('base64url') },
|
||
},
|
||
});
|
||
const owner = await superQuery(
|
||
`SELECT employee_id FROM staff_push_subscriptions WHERE tenant_id = $1 AND endpoint = $2`,
|
||
[TENANT, 'https://127.0.0.1:18443/internal-admin'],
|
||
).catch((error) => ({ rows: [{ employee_id: `ошибка: ${error.message}` }] }));
|
||
record(
|
||
'N9',
|
||
rebind.status < 300 && owner.rows[0]?.employee_id === ids.employee ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||
`сотрудник Б подписывает endpoint сотрудника А → ${rebind.status}; подписка теперь принадлежит сотруднику Б: ${owner.rows[0]?.employee_id === ids.employee}`,
|
||
);
|
||
|
||
// A4: internal error details leak in 500 responses
|
||
const badFk = await call('POST', '/employees', {
|
||
token: MANAGER_TOKEN,
|
||
body: { personnel_number: 'A-200', display_name: 'X', profession_id: ulid() },
|
||
});
|
||
record('A4', 'проверка', `500 с деталями: POST /employees с несуществующей профессией → ${badFk.status} ${brief(badFk.json)}`);
|
||
|
||
// N10: reference data without permission checks
|
||
const professionsByTerminal = await call('GET', '/professions', { token: TERMINAL_TOKEN });
|
||
const orgUnitsByTerminal = await call('GET', '/org-units', { token: TERMINAL_TOKEN });
|
||
record(
|
||
'N10',
|
||
'проверка',
|
||
`терминал: GET /professions → ${professionsByTerminal.status} (${Array.isArray(professionsByTerminal.json) ? professionsByTerminal.json.length : brief(professionsByTerminal.json)}); GET /org-units → ${orgUnitsByTerminal.status} ${brief(orgUnitsByTerminal.json)}`,
|
||
);
|
||
|
||
// Security headers on the portal
|
||
const page = await fetch(`http://127.0.0.1:${PORT}/staff`);
|
||
record(
|
||
'HDR',
|
||
'проверка',
|
||
`GET /staff: content-security-policy="${page.headers.get('content-security-policy')}", strict-transport-security="${page.headers.get('strict-transport-security')}"`,
|
||
);
|
||
} finally {
|
||
listener.close();
|
||
}
|
||
} finally {
|
||
const serverErrors = server.stderrText();
|
||
await stop(server);
|
||
if (serverErrors.trim()) console.log(`server stderr: ${brief(serverErrors)}`);
|
||
}
|
||
}
|
||
|
||
await scenarioOpenMode('manager');
|
||
await scenarioOpenMode('employee');
|
||
await scenarioWithTokens();
|
||
console.log('\nJSON', JSON.stringify(results));
|