http-attack 0.0.0-stage → 1.0.1-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { UsageError } from '../errors.mjs';
5
+ import { granted, is2xx } from '../http.mjs';
6
+ import { wire } from '../body.mjs';
7
+ import { itemLabel, itemUrlOf } from './auth.mjs';
8
+
9
+ const real = (p) => { try { return realpathSync(p); } catch { return p; } };
10
+
11
+ // The state file is read and written by the tool: keep it inside the working directory, the temp directory or the config's persistDir.
12
+ export const checkPersistPath = (file, config, cwd) => {
13
+ const abs = resolve(cwd, file);
14
+ const target = join(real(dirname(abs)), basename(abs));
15
+ const roots = [cwd, tmpdir(), config.persistDir].filter(Boolean).map(real);
16
+ const inside = roots.some((root) => { const rel = relative(root, target); return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); });
17
+ if (!inside) throw new UsageError(`${file} is outside the working directory and the temp directory: set "persistDir" in the config file to allow it.`);
18
+ };
19
+
20
+ const canonical = (value, ignore) => {
21
+ if (Array.isArray(value)) return value.map((v) => canonical(v, ignore));
22
+ if (value && typeof value === 'object') {
23
+ return Object.fromEntries(Object.keys(value).filter((k) => !ignore.includes(k)).sort().map((k) => [k, canonical(value[k], ignore)]));
24
+ }
25
+ return value;
26
+ };
27
+
28
+ // Compares two bodies; JSON bodies ignore the named fields (timestamps, request ids) at any depth.
29
+ export const sameBody = (a, b, ignore = []) => {
30
+ try { return JSON.stringify(canonical(JSON.parse(a), ignore)) === JSON.stringify(canonical(JSON.parse(b), ignore)); } catch { return a === b; }
31
+ };
32
+
33
+ // Persistence is a two-step run around a restart. Returns { ok, message, data } for the CLI to print.
34
+ // A file that is not a previous persist state is never overwritten (it could be a package.json).
35
+ const refuseToOverwrite = (file) => {
36
+ let existing;
37
+ try { existing = readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return; throw new UsageError(`--persist-save: cannot read ${file}: ${e.code ?? e.message}`); }
38
+ let state;
39
+ try { state = JSON.parse(existing); } catch { /* not JSON */ }
40
+ if (typeof state?.item !== 'string' || !('status' in state) || !('body' in state)) throw new UsageError(`--persist-save: ${file} exists and is not a state file written by --persist-save: it is not overwritten.`);
41
+ };
42
+
43
+ export async function persistSave(run, file) {
44
+ const w = run.opts.writes[0];
45
+ if (!w) throw new UsageError('--persist-save needs a --write.');
46
+ refuseToOverwrite(file);
47
+ const p = wire(w, run.settings(w.method, w.path));
48
+ const res = await run.client.send(w.method, w.path, { body: p.text, type: p.type, common: false });
49
+ if (res.s === null) throw new Error(`the create did not answer: ${res.err}`);
50
+ if (!granted(res)) throw new UsageError(res.s >= 300 ? `The create was redirected to login (${res.headers.location}): the credentials do not work.` : `The create answered ${res.s}: nothing was saved.`);
51
+ const item = itemUrlOf(res,{ base: run.client.base, itemPath: run.itemPath });
52
+ if (!item) throw new UsageError(`The create answered ${res.s} without a readable location (give --item-path).`);
53
+ const read = await run.client.send('GET', item, { common: false });
54
+ try { writeFileSync(file, JSON.stringify({ item, status: read.s, body: read.text }, null, 2)); } catch (e) { throw new Error(`cannot write ${file}: ${e.code ?? e.message}`); }
55
+ return { ok: is2xx(read.s), message: `saved ${item} (${read.s}) to ${file}. Restart the app, then run --persist-verify ${file}.`, data: { mode: 'persist-save', item, status: read.s } };
56
+ }
57
+
58
+ export async function persistVerify(run, file) {
59
+ let saved;
60
+ try { saved = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new UsageError(`--persist-verify: cannot read ${file}: ${e.code ?? e.message}`); }
61
+ if (typeof saved?.item !== 'string') throw new UsageError(`--persist-verify: ${file} is not a file written by --persist-save.`);
62
+ const now = await run.client.send('GET', saved.item, { common: false });
63
+ const same = now.s === saved.status && sameBody(now.text, saved.body, run.config.persistIgnore);
64
+ return {
65
+ ok: same,
66
+ message: `${same ? 'PASS' : 'FAIL critical'} GET ${saved.item} after restart: ${now.s} (before: ${saved.status})${same ? '' : ' - expected the same status and body: the data is not durable'}`,
67
+ data: { mode: 'persist-verify', ...(same ? {} : { id: `not-durable:persist-verify:GET ${itemLabel(saved.item)}`, rule: 'not-durable', severity: 'critical' }), item: saved.item, status: now.s, savedStatus: saved.status },
68
+ };
69
+ }
@@ -0,0 +1,124 @@
1
+ import { connect } from 'node:net';
2
+ import { wire } from '../body.mjs';
3
+
4
+ // Identical creates at once: check-then-insert lets more than one through, or turns the constraint violation into a 500.
5
+ // Last-byte synchronization over raw sockets: every connection gets its request minus the final byte, then the final bytes go
6
+ // out in one synchronous loop. Three details keep the racers inside one server read batch:
7
+ // - Nagle is off: a 1-byte write behind unacknowledged data otherwise waits for the peer's delayed ACK (~200 ms on Windows).
8
+ // - A pacer request (same endpoint, different key) is released a few ms first. An event loop wakes on the first byte that
9
+ // arrives and handles that request alone; if the handler then blocks the loop (a synchronous fsync takes ~100 ms on
10
+ // Windows), its timers fire before the next read, and requests that arrived a microsecond apart are served one by one.
11
+ // The pacer takes that first wake-up, and the racers arrive while the loop is busy with it.
12
+ // - Every dropped connection is reported with its error code (ECONNRESET, EMPTY_RESPONSE, TIMEOUT...).
13
+ const PACER_LEAD_MS = 5;
14
+ const SETTLE_MS = 50;
15
+ const SOCKET_TIMEOUT_MS = 10_000;
16
+
17
+ const decodeChunked = (text) => {
18
+ let out = '', rest = text;
19
+ for (;;) {
20
+ const nl = rest.indexOf('\r\n');
21
+ const size = parseInt(rest.slice(0, nl), 16);
22
+ if (nl < 0 || !Number.isInteger(size) || size === 0) return out;
23
+ out += rest.slice(nl + 2, nl + 2 + size);
24
+ rest = rest.slice(nl + 2 + size + 2);
25
+ }
26
+ };
27
+
28
+ // The response parsed from the bytes read until the server closed the socket. A response whose body is shorter than its
29
+ // Content-Length, or whose chunked body has no terminating chunk, was cut: `complete` is false.
30
+ const parseResponse = (buf) => {
31
+ const end = buf.indexOf('\r\n\r\n');
32
+ const head = buf.subarray(0, end < 0 ? buf.length : end).toString('latin1');
33
+ const m = head.match(/^HTTP\/1\.[01] (\d{3})/);
34
+ if (!m) return null;
35
+ const headers = Object.fromEntries(head.split('\r\n').slice(1).filter((l) => l.includes(':'))
36
+ .map((l) => [l.slice(0, l.indexOf(':')).trim().toLowerCase(), l.slice(l.indexOf(':') + 1).trim()]));
37
+ const body = end < 0 ? Buffer.alloc(0) : buf.subarray(end + 4);
38
+ const chunked = headers['transfer-encoding'] === 'chunked';
39
+ const length = headers['content-length'] === undefined ? null : Number(headers['content-length']);
40
+ const complete = end >= 0 && (chunked ? /(^|\r\n)0\r\n\r\n$/.test(body.toString('latin1')) : length === null || body.length >= length);
41
+ const text = body.toString('utf8');
42
+ return { s: Number(m[1]), headers, text: chunked ? decodeChunked(text) : text, complete };
43
+ };
44
+
45
+ // The identity of the item a success created: its Location, else the id in the JSON body.
46
+ const identityOf = (r) => {
47
+ if (r.headers.location) return r.headers.location;
48
+ try { const j = JSON.parse(r.text); const id = j?.id ?? j?.data?.id ?? j?.item?.id; return id === undefined ? null : `id:${id}`; } catch { return null; }
49
+ };
50
+
51
+ export async function raceProbe(run, u) {
52
+ const { client, opts } = run;
53
+ const target = `${u.method} ${u.path}`;
54
+ const spec = { group: 'concurrency', probe: 'race-identical-creates', target, name: `${target} x${opts.raceCount} identical requests at once` };
55
+ const st = run.settings(u.method, u.path);
56
+ const expected = `exactly one 201, the other ${opts.raceCount - 1} 409`;
57
+ const bytesOf = (p) => {
58
+ const data = Buffer.from(p.text);
59
+ const head = Object.entries({ host: client.base.host, ...opts.headers, 'content-type': p.type, 'content-length': data.length, connection: 'close' })
60
+ .map(([k, v]) => `${k}: ${v}`).join('\r\n');
61
+ return Buffer.concat([Buffer.from(`${u.method} ${u.path} HTTP/1.1\r\n${head}\r\n\r\n`), data]);
62
+ };
63
+ const open = (bytes) => new Promise((ready) => {
64
+ const sock = connect({ host: client.host, port: client.port, noDelay: true });
65
+ const chunks = [];
66
+ let err = null, settle;
67
+ const settled = new Promise((r) => { settle = r; });
68
+ sock.setTimeout(SOCKET_TIMEOUT_MS, () => { err ??= 'TIMEOUT'; sock.destroy(); });
69
+ sock.on('data', (d) => chunks.push(d));
70
+ sock.on('error', (e) => { err ??= e.code ?? e.message; ready({ sock: null, settled }); });
71
+ sock.on('close', () => {
72
+ const bytes = Buffer.concat(chunks);
73
+ const parsed = parseResponse(bytes);
74
+ if (!parsed) return settle({ s: null, err: err ?? (bytes.length ? 'BAD_RESPONSE' : 'EMPTY_RESPONSE') });
75
+ if (!parsed.complete) return settle({ s: null, err: err ?? 'TRUNCATED_RESPONSE' });
76
+ client.noteHeaders(u.method, u.path, parsed.s, parsed.headers);
77
+ settle(parsed);
78
+ });
79
+ sock.on('connect', () => sock.write(bytes.subarray(0, -1), () => ready({ sock, settled, last: bytes.subarray(-1) })));
80
+ });
81
+
82
+ let last = null;
83
+ for (let attempt = 1; attempt <= opts.raceAttempts; attempt++) {
84
+ const racerBytes = bytesOf(wire(u, st));
85
+ const [pacer, ...racers] = await Promise.all([open(bytesOf(wire(u, st))), ...Array.from({ length: opts.raceCount }, () => open(racerBytes))]);
86
+ await new Promise((r) => setTimeout(r, SETTLE_MS));
87
+ if (pacer.sock && !pacer.sock.destroyed) pacer.sock.write(pacer.last);
88
+ await new Promise((r) => setTimeout(r, PACER_LEAD_MS));
89
+ for (const c of racers) if (c.sock && !c.sock.destroyed) c.sock.write(c.last);
90
+ const [, ...all] = await Promise.all([pacer.settled, ...racers.map((c) => c.settled)]);
91
+ const verdict = judge(all);
92
+ last = { ...verdict, attempt };
93
+ if (verdict.kind !== 'pass') break;
94
+ }
95
+
96
+ function judge(all) {
97
+ const codes = all.map((x) => x.s ?? `drop:${x.err}`);
98
+ const created = all.filter((x) => x.s >= 200 && x.s < 300);
99
+ const five = all.filter((x) => x.s >= 500).length;
100
+ const drop = all.filter((x) => x.s === null).length;
101
+ const joined = codes.join(' ');
102
+ if (drop === all.length) return { kind: 'not-run', codes: joined, why: `no response at all (${joined})` };
103
+ if (!created.length && !five && !drop) return { kind: 'not-run', codes: joined, why: `none succeeded (${joined}): the legitimate body is refused, so the race was not tried` };
104
+ if (created.length > 1 && !st.idempotentCreate) {
105
+ const ids = created.map(identityOf);
106
+ const distinct = new Set(ids.filter(Boolean));
107
+ if (ids.some((x) => x === null) || distinct.size > 1) {
108
+ return { kind: 'duplicates', codes: joined, why: `${created.length} succeeded${distinct.size > 1 ? ` with ${distinct.size} distinct items` : ''}: duplicates stored; the unique key is not enforced by the database (a create that is idempotent by design: idempotentCreate in the config)` };
109
+ }
110
+ return { kind: 'pass', codes: joined, why: 'several successes returned the same item: the create is idempotent' };
111
+ }
112
+ if (five) return { kind: 'five', codes: joined, why: 'the constraint violation escapes as a 5xx: map the unique-violation error to 409', sample: all.find((x) => x.s >= 500) };
113
+ if (drop) return { kind: 'drop', codes: joined, why: 'the constraint violation closes the connection: map the unique-violation error to 409' };
114
+ if (codes.some((s) => s !== 409 && s >= 400)) return { kind: 'pass', codes: joined, why: 'refusals are 4xx but not 409; fine if the contract says so' };
115
+ return { kind: 'pass', codes: joined };
116
+ }
117
+
118
+ const at = last.attempt > 1 || opts.raceAttempts > 1 ? ` (attempt ${last.attempt} of ${opts.raceAttempts})` : '';
119
+ if (last.kind === 'not-run') run.notRun(spec, last.why);
120
+ else if (last.kind === 'duplicates') run.fail('duplicates-stored', spec, last.codes, expected, last.why + at);
121
+ else if (last.kind === 'five') await run.transportFault(last.sample, { ...spec, hint: last.why }, expected);
122
+ else if (last.kind === 'drop') run.fail('connection-dropped', spec, last.codes, expected, last.why + at);
123
+ else run.pass(spec, last.codes, expected, last.why);
124
+ }
@@ -0,0 +1,44 @@
1
+ import { is4xx } from '../http.mjs';
2
+ import { isJsonBody, wire } from '../body.mjs';
3
+
4
+ const MIB = 1 << 20;
5
+ const BIG = 50 * MIB;
6
+ const DEFAULT_LIMIT = 2 * MIB;
7
+ const MAX_STREAM = 64 * MIB;
8
+ const TEXT_PLAIN_WAIT_MS = 3000;
9
+ const FAST_REJECT_MS = 2000;
10
+
11
+ const mb = (n) => (n / MIB).toFixed(1);
12
+
13
+ // Bodies that cost the server something: a big one with the wrong media type, and one that never ends.
14
+ export async function resourceProbes(run, w) {
15
+ const { client, opts } = run;
16
+ const target = `${w.method} ${w.path}`;
17
+ const st = run.settings(w.method, w.path);
18
+ const spec = (probe, name) => ({ group: 'resources', probe, target, name: `${target} ${name}` });
19
+ const json = isJsonBody(w);
20
+ const p = wire(w, st);
21
+
22
+ const big = spec('text-plain-50mb', '50 MB text/plain body');
23
+ if (!json) run.notRun(big, 'the write body is not JSON: the wrong-media-type probe sends text/plain to a JSON route');
24
+ else {
25
+ const expected = '415 (or 413) at the first bytes, before reading the body';
26
+ const v = await client.stream(w.method, w.path, { 'content-type': 'text/plain', 'content-length': BIG }, BIG, 64 << 10, { stopAfterMs: TEXT_PLAIN_WAIT_MS });
27
+ if (!(await client.alive())) run.fail('process-died', big, v.s ?? v.code, expected, 'the app stopped answering');
28
+ else if (v.kind === 'status' && is4xx(v.s) && (v.sent < BIG || v.ms < FAST_REJECT_MS)) run.pass(big, v.s, expected);
29
+ else if (v.kind === 'status' && is4xx(v.s)) run.fail('read-before-reject', big, v.s, expected, `answered only after reading all 50 MB (${v.ms} ms)`);
30
+ else if (v.kind === 'closed' && v.sent < BIG) run.pass(big, 'closed', expected);
31
+ else run.fail('no-body-limit', big, v.s ?? v.kind, expected, `${mb(v.sent)} MB sent`);
32
+ }
33
+
34
+ const endless = spec('body-never-ends', 'body that never ends (chunked)');
35
+ const limit = st.bodyLimit ?? DEFAULT_LIMIT;
36
+ if (limit + 1 > MAX_STREAM) { run.notRun(endless, `bodyLimit ${limit} is above the ${mb(MAX_STREAM)} MB this probe will send`); return; }
37
+ const expected = '413 once over the limit, or 408 / connection closed within the request timeout';
38
+ const v = await client.stream(w.method, w.path, { 'content-type': p.type, 'transfer-encoding': 'chunked' }, limit + 1, 64 << 10,
39
+ { stopAfterMs: opts.hangMs, prefix: json ? '{"x":"' : 'x=' });
40
+ if (!(await client.alive())) run.fail('process-died', endless, v.s ?? v.code, expected, 'the app stopped answering');
41
+ else if (v.kind === 'silent') run.fail('no-body-limit', endless, 'no answer', expected, `still reading after ${mb(v.sent)} MB (the limit is ${st.bodyLimit ? `${limit} bytes` : 'unknown: 2 MB assumed'}) and ${opts.hangMs} ms: no body limit, no timeout`);
42
+ else if (v.kind === 'status' && !is4xx(v.s)) run.fail('no-body-limit', endless, v.s, expected);
43
+ else run.pass(endless, v.s ?? 'closed', expected);
44
+ }
@@ -0,0 +1,74 @@
1
+ import { is2xx } from '../http.mjs';
2
+ import { nextCounter } from '../body.mjs';
3
+
4
+ const ENVELOPE_KEYS = ['data', 'items', 'results', 'rows', 'records', 'content', 'edges', 'hits'];
5
+
6
+ // The rows of a list response: a JSON array, or the array inside an envelope. null when the body is not a list.
7
+ export function rowsOf(text) {
8
+ let v;
9
+ try { v = JSON.parse(text); } catch { return null; }
10
+ if (Array.isArray(v)) return v;
11
+ if (v && typeof v === 'object') {
12
+ const key = ENVELOPE_KEYS.find((k) => Array.isArray(v[k])) ?? Object.keys(v).find((k) => Array.isArray(v[k]));
13
+ if (key) return v[key];
14
+ }
15
+ return null;
16
+ }
17
+ const VALUES = [
18
+ ['percent', '%'], ['underscore', '_'], ['quote-single', "'"], ['quote-double', '"'], ['backslash', '\\'],
19
+ ['injection-or-number', "' OR 1=1 --"], ['injection-or-string', "%' OR '1'='1"],
20
+ ];
21
+
22
+ // The text values of the bodies the run created, as search terms: a row the run created is a row known to exist.
23
+ const termsOf = (bodies) => {
24
+ const terms = [];
25
+ const walk = (v) => {
26
+ if (typeof v === 'string') { if (v.length >= 2 && v.length <= 80) terms.push(v); } else if (v && typeof v === 'object') Object.values(v).forEach(walk);
27
+ };
28
+ for (const body of bodies) { try { walk(JSON.parse(body)); } catch { /* not JSON: no terms */ } }
29
+ return [...new Set(terms)];
30
+ };
31
+
32
+ // Free text taken literally: % and _ are LIKE wildcards; a quote that breaks the SQL is an injection.
33
+ // Rows are compared as parsed rows against a control search that matches nothing. `created` holds the bodies of the
34
+ // writes that succeeded.
35
+ export async function searchProbes(run, { path: route, param }, created = []) {
36
+ const { client } = run;
37
+ const [p, q] = route.split('?');
38
+ const target = `GET ${p}`;
39
+ const spec = (probe, name) => ({ group: 'search', probe, target, name });
40
+ const at = (v) => { const sp = new URLSearchParams(q ?? ''); if (v === null) sp.delete(param); else sp.set(param, v); const s = sp.toString(); return s ? `${p}?${s}` : p; };
41
+
42
+ const all = await client.send('GET', at(null));
43
+ const control = await client.send('GET', at(`zz-no-match-${nextCounter()}`));
44
+ const allRows = is2xx(all.s) ? rowsOf(all.text) : null;
45
+ const controlRows = is2xx(control.s) ? rowsOf(control.text) : null;
46
+ // The unfiltered list is optional (some endpoints require the parameter): the control search is the reference.
47
+ // Without it, a row must be known to exist: one the run created, found again by an exact-match search.
48
+ let blocked = null;
49
+ if (allRows && allRows.length === 0) blocked = 'the unfiltered list is empty: create a row first, or the wildcard and injection checks prove nothing';
50
+ else if (!controlRows) blocked = `a search that matches nothing did not answer a list of rows (${control.s ?? control.err})`;
51
+ else if (controlRows.length > 0) blocked = `the ${param} parameter does not filter: a search that matches nothing returned ${controlRows.length} rows`;
52
+ else if (!allRows) {
53
+ let found = false;
54
+ for (const term of termsOf(created)) {
55
+ const hit = await client.send('GET', at(term));
56
+ if (is2xx(hit.s) && (rowsOf(hit.text) ?? []).some((row) => JSON.stringify(row).includes(term))) { found = true; break; }
57
+ }
58
+ if (!found) blocked = 'the unfiltered list needs the parameter and no row created by --write was found by an exact search: without a row known to exist, a pass proves nothing';
59
+ }
60
+
61
+ for (const [tag, v] of VALUES) {
62
+ const s = spec(tag, `GET ${at(v)}`);
63
+ const expected = '200 with only the rows that contain that literal text (usually none)';
64
+ const res = await client.send('GET', at(v));
65
+ if (await run.transportFault({ ...res }, { ...s, hint: /'/.test(v) ? 'a quote breaks the query: SQL built by concatenation, or a driver that rejects the literal (to_tsquery): confirm' : undefined }, expected)) continue;
66
+ if (blocked) { run.notRun(s, blocked); continue; }
67
+ const rows = is2xx(res.s) ? rowsOf(res.text) : null;
68
+ if (rows && (tag === 'percent' || tag === 'underscore') && rows.length > controlRows.length) {
69
+ run.fail('wildcard-unescaped', s, res.s, expected, `${v} matched ${rows.length} rows while a search that matches nothing matched none: it reached LIKE unescaped`);
70
+ } else if (rows && tag.startsWith('injection') && rows.length > controlRows.length) {
71
+ run.fail('sql-injection', s, res.s, expected, 'the injected OR returned rows, a search that matches nothing returned none');
72
+ } else run.pass(s, res.s, expected);
73
+ }
74
+ }
@@ -0,0 +1,39 @@
1
+ // The single registry of finding rules. SKILL.md's severity table is checked against it by the test suite.
2
+ // A finding id is `<rule>:<probe>:<target>`; the probe and the target never contain generated values (row ids, run tags).
3
+
4
+ export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
5
+
6
+ export const RULES = {
7
+ 'process-died': { severity: 'critical', meaning: 'the app stopped answering during a probe' },
8
+ 'unauthenticated-access': { severity: 'critical', meaning: 'a write or read is granted (2xx) without credentials' },
9
+ idor: { severity: 'critical', meaning: "another user's credentials reach this user's object (2xx)" },
10
+ 'sql-injection': { severity: 'critical', meaning: 'an injected OR returns rows that a no-match control does not' },
11
+ 'duplicates-stored': { severity: 'critical', meaning: 'concurrent identical creates stored more than one distinct item' },
12
+ 'file-served': { severity: 'critical', meaning: 'a repo, secret or OS file came back over HTTP' },
13
+ csrf: { severity: 'critical', meaning: 'a cookie-authenticated write from a foreign Origin was accepted' },
14
+ 'not-durable': { severity: 'critical', meaning: 'a saved item is gone or different after a restart' },
15
+ 'connection-dropped': { severity: 'high', meaning: 'the connection was closed without a response while the app stayed up' },
16
+ 'server-error': { severity: 'high', meaning: 'a 5xx on client input (including path params, quotes and unique violations)' },
17
+ 'accepted-invalid': { severity: 'high', meaning: 'input that must be refused got a 2xx/3xx (bad JSON, invalid UTF-8, wrong type, other methods)' },
18
+ 'no-body-limit': { severity: 'high', meaning: 'a body over the limit is accepted, or a body that never ends is read forever' },
19
+ 'double-slash-mismatch': { severity: 'high', meaning: '`//x` answers 2xx with something other than `/x`' },
20
+ 'wildcard-unescaped': { severity: 'high', meaning: '`%` or `_` in a search reach LIKE as wildcards' },
21
+ 'parser-accepted': { severity: 'high', meaning: 'the HTTP parser let a malformed raw request through' },
22
+ 'fault-not-reported': { severity: 'high', meaning: 'a write during a database fault was not answered with a 5xx' },
23
+ 'limit-counts-characters': { severity: 'medium', meaning: 'the body limit counts characters, not bytes' },
24
+ 'read-before-reject': { severity: 'medium', meaning: 'a 50 MB body was read in full before the 415' },
25
+ 'media-type-strict': { severity: 'medium', meaning: 'a case or parameter variant of the media type is refused (RFC 9110 8.3.1)' },
26
+ 'stack-leak': { severity: 'medium', meaning: 'a 5xx body leaks a stack trace, driver error or SQLSTATE' },
27
+ 'parser-no-common-headers': { severity: 'medium', meaning: "a parser-level error lacks the app's common headers" },
28
+ 'headers-missing': { severity: 'medium', meaning: 'a response lacks a header every response must carry' },
29
+ 'missing-allow': { severity: 'low', meaning: '405 without an `Allow` header' },
30
+ 'head-unsupported': { severity: 'low', meaning: 'HEAD on a readable route answers 405 (RFC 9110 9.1)' },
31
+ 'not-run': { severity: null, meaning: 'a probe could not reach a verdict; never a pass' },
32
+ };
33
+
34
+ export const severityOf = (rule) => {
35
+ if (!(rule in RULES)) throw new Error(`unknown rule ${rule}`);
36
+ return RULES[rule].severity;
37
+ };
38
+
39
+ export const EXIT = { OK: 0, FINDINGS: 1, ERROR: 2 };
@@ -0,0 +1,63 @@
1
+ import { EXIT, SEVERITIES } from './registry.mjs';
2
+
3
+ export const SCHEMA_VERSION = 1;
4
+
5
+ export function summarize(rows) {
6
+ const count = (fn) => rows.filter(fn).length;
7
+ const bySeverity = Object.fromEntries(SEVERITIES.map((s) => [s, count((r) => r.verdict === 'fail' && r.severity === s)]));
8
+ const passed = count((r) => r.verdict === 'pass');
9
+ const failed = count((r) => r.verdict === 'fail');
10
+ const suppressed = count((r) => r.verdict === 'suppressed');
11
+ return {
12
+ run: passed + failed + count((r) => r.verdict === 'suppressed' && r.was === 'fail'),
13
+ passed, failed, suppressed,
14
+ notRun: count((r) => r.verdict === 'not-run'),
15
+ skipped: count((r) => r.verdict === 'skipped'),
16
+ bySeverity,
17
+ };
18
+ }
19
+
20
+ // 0 clean, 1 findings, 2 nothing could be concluded: no probe ran, or some probe could not run.
21
+ export function exitCode(summary) {
22
+ if (summary.failed > 0) return EXIT.FINDINGS;
23
+ if (summary.run === 0 || summary.notRun > 0) return EXIT.ERROR;
24
+ return EXIT.OK;
25
+ }
26
+
27
+ const ORDER = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
28
+
29
+ export function renderJson({ base, rows, summary, unusedIgnores, unusedRoutes = [] }) {
30
+ const findings = rows.filter((r) => r.verdict === 'fail').sort((a, b) => ORDER[a.severity] - ORDER[b.severity]);
31
+ return JSON.stringify({
32
+ schemaVersion: SCHEMA_VERSION,
33
+ base,
34
+ summary: { ...summary, exitCode: exitCode(summary) },
35
+ findings: findings.map((r) => r.id),
36
+ unusedIgnores: unusedIgnores.map((i) => i.id),
37
+ unusedRoutes,
38
+ rows: rows.map(({ id, rule, group, probe, target, name, verdict, severity, got, expected, evidence, was, suppressedBy }) =>
39
+ ({ id, rule: rule ?? null, group, probe, target, name, verdict, severity: severity ?? null, got: got ?? '', expected: expected ?? '', evidence: evidence ?? null, was: was ?? null, suppressedBy: suppressedBy ?? null })),
40
+ }, null, 2);
41
+ }
42
+
43
+ const MARK = { fail: (r) => `FAIL ${r.severity.padEnd(8)}`, 'not-run': () => 'NOT RUN ', pass: () => 'pass ', skipped: () => 'skipped ', suppressed: () => 'suppressed ' };
44
+
45
+ export function renderText({ rows, summary, unusedIgnores, unusedRoutes = [] }) {
46
+ const out = [];
47
+ const pad = ' '.repeat(20);
48
+ for (const r of rows) {
49
+ out.push(`${MARK[r.verdict](r)} ${(r.got || '-').padEnd(5)} [${r.group}] ${r.name}`);
50
+ if (r.verdict === 'pass') continue;
51
+ if (r.verdict === 'suppressed') { out.push(`${pad}${r.was === 'fail' ? 'finding' : 'not run'} suppressed: ${r.suppressedBy}`, `${pad}id: ${r.id}`); continue; }
52
+ if (r.verdict === 'skipped') { out.push(`${pad}${r.evidence}`); continue; }
53
+ out.push(`${pad}expected: ${r.expected || '-'}`);
54
+ if (r.evidence) out.push(`${pad}evidence: ${r.evidence}`);
55
+ out.push(`${pad}id: ${r.id}`);
56
+ }
57
+ for (const i of unusedIgnores) out.push(`warning: the ignore "${i.id}" matched nothing`);
58
+ for (const k of unusedRoutes) out.push(`warning: the route settings "${k}" matched no request`);
59
+ const s = summary;
60
+ out.push('', `${s.run} probes run - ${s.failed} findings (critical ${s.bySeverity.critical}, high ${s.bySeverity.high}, medium ${s.bySeverity.medium}, low ${s.bySeverity.low})`
61
+ + ` - ${s.suppressed} suppressed - ${s.notRun} not run${s.skipped ? ` - ${s.skipped} skipped by configuration` : ''}`);
62
+ return out.join('\n');
63
+ }
@@ -0,0 +1,58 @@
1
+ import { routeSettings } from './config.mjs';
2
+ import { severityOf } from './registry.mjs';
3
+ import { is5xx } from './http.mjs';
4
+
5
+ // Stack traces and driver errors from Node, Python, Go, Java, Rails and SQL drivers.
6
+ const LEAK = new RegExp([
7
+ String.raw`\bat \S+ \(`, String.raw`\bat [\w$.<>]+\([\w$]+\.(?:java|kt|scala):\d+\)`, 'node:internal', String.raw`Traceback \(most recent`,
8
+ String.raw`File "[^"]+", line \d+`, 'panic: ', String.raw`goroutine \d+ \[`, String.raw`ActiveRecord::`, String.raw`ActionController::`,
9
+ 'SQLSTATE', 'SQLITE_', String.raw`sqlite3\.`, String.raw`\bPG::`, 'psycopg', String.raw`org\.postgresql`, String.raw`com\.mysql`,
10
+ String.raw`java\.lang\.\w+Exception`, String.raw`\.rb:\d+:in `,
11
+ ].join('|'), 'i');
12
+ export const leaksInternals = (text) => LEAK.test(text);
13
+
14
+ const snippet = (text) => text.replace(/\s+/g, ' ').slice(0, 120);
15
+
16
+ // Collects the rows of one run. `spec` is { group, probe, target, name }: probe and target form the stable id.
17
+ export class Run {
18
+ constructor({ client, opts, config }) {
19
+ Object.assign(this, { client, opts, config, rows: [] });
20
+ }
21
+
22
+ settings(method, path) { return routeSettings(this.config, this.opts, method, path); }
23
+
24
+ add(row) { this.rows.push({ ...row }); }
25
+
26
+ pass(spec, got, expected, evidence) {
27
+ this.add({ ...spec, id: `ok:${spec.probe}:${spec.target}`, verdict: 'pass', got: String(got ?? ''), expected, evidence });
28
+ }
29
+
30
+ fail(rule, spec, got, expected, evidence) {
31
+ this.add({ ...spec, id: `${rule}:${spec.probe}:${spec.target}`, rule, verdict: 'fail', severity: severityOf(rule), got: String(got ?? ''), expected, evidence });
32
+ }
33
+
34
+ notRun(spec, why) {
35
+ this.add({ ...spec, id: `not-run:${spec.probe}:${spec.target}`, rule: 'not-run', verdict: 'not-run', got: '', expected: '', evidence: why });
36
+ }
37
+
38
+ // Not applicable because the configuration says so (not a gap, not a pass).
39
+ skipped(spec, why) {
40
+ this.add({ ...spec, id: `skipped:${spec.probe}:${spec.target}`, verdict: 'skipped', got: '', expected: '', evidence: why });
41
+ }
42
+
43
+ // A response that is a failure whatever the probe was: no answer, a dead app, a 5xx (with its leak check).
44
+ // Returns true when it recorded the outcome.
45
+ async transportFault(res, spec, expected) {
46
+ if (res.s === null || res.truncated) {
47
+ if (await this.client.alive()) this.fail('connection-dropped', spec, res.err, expected, 'the connection was closed without a complete response');
48
+ else this.fail('process-died', spec, res.err, expected, 'the app stopped answering');
49
+ return true;
50
+ }
51
+ if (is5xx(res.s)) {
52
+ this.fail('server-error', spec, res.s, expected, spec.hint);
53
+ if (leaksInternals(res.text ?? '')) this.fail('stack-leak', spec, res.s, 'a fixed error body without internals', `the body shows: ${snippet(res.text)}`);
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ }