Files
Lauri Ojansivu 3d08c65bb2 Admin Panel Problems: one summary row per problem, with who tried and how often.
A guard on a path an attacker controls fires as fast as they can send. One
document per event meant the database grew with the attack, the Problems page
became a scroll of near-identical lines, and the one event that mattered was
buried under ten thousand that did not. The admin's question is never "list
every attempt" - it is what is happening, how much, since when, and who.

So each problem is ONE row that accumulates:

  count            how many times this problem has happened
  firstAt … at     the window it happened in
  actors           who, each with their own count:
                     username1 25, 100.100.100.100 30
  username/ip      the most recent occurrence

WHAT MAKES TWO EVENTS THE SAME PROBLEM is the whole design. Identity is the KIND
of thing that happened - stream, bleed, category, action, source, severity, CWE,
and the database stream's own type/db/kind. The actor is NOT part of it: a
username or an address in the key gives a row per attacker per attempt, which is
the cost being removed.

A USERNAME AND AN ADDRESS ARE TALLIED SEPARATELY, not as a pair. They answer
different questions - which account, and where from - and an unauthenticated
attempt has an address and no name. Pairing them would split one attacker across
a line per address and answer neither.

THE TALLY IS CAPPED at 50 actors, with the rest counted in actorsOverflow.
Otherwise an attacker rotating addresses grows the row with the attack and
reintroduces the same bug one level down - and "and 9,412 others" is itself the
signal that the source is spread rather than single. The cap needs to know
whether an actor is new to the row, so the row's actor keys are cached per
process: reading it on every attempt would double the database work of the thing
being made cheap, and under attack the same few actors repeat.

All three loggers share the fold (server/lib/eventLogFold.js) - a slow query in
a loop and a test failing on every run repeat just as a guard under attack does.
Still fire-and-forget and still unable to throw into the caller.

EXISTING PER-EVENT ROWS ARE FOLDED IN PLACE at startup, in batches, in JS so it
behaves the same on FerretDB as on MongoDB, and idempotently - a row with firstAt
is already a summary. Nothing is lost that the summary does not carry: counts are
summed, the earliest becomes firstAt, the latest becomes at, and the latest
occurrence's actor and detail are what the row keeps.

AND A COVERAGE CHECK, as asked: tests/hallOfFameProblemsCoverage.test.cjs
requires every hall-of-fame vulnerability to be keyed, deliberate (with the
reason, as HashBleed is), or pending. 30 of 66 are keyed; the other 35 predate
the rule and are listed as pending rather than judged here, because deciding
whether a given fix has anything to detect means reading that fix and claiming
otherwise would be worse than the gap. The pending list may shrink and may never
grow, so a NEW vulnerability without a key fails.

It found one real fault immediately: the catalog said OidcBleed and the site says
OIDCBleed, so attempts were grouping under a name nobody could look up.

Thanks to xet7 !
2026-08-16 14:57:18 +03:00

39 lines
1.7 KiB
JavaScript

// ============================================================================
// WeKan test-failure logger (docs/Security/Remediation/WeKan.md §9b)
// ----------------------------------------------------------------------------
// Logs "anything that would fail some existing WeKan test": one document per
// FAILING test (passing tests are not logged) in the existing WeKan database
// (models/eventLog.js, stream:'tests') via a normal Meteor JavaScript query.
// Surfaced in Admin Panel → Reports → Tests. Best-effort, never throws.
// Server-only.
// ============================================================================
import EventLog from '/models/eventLog';
// One row per problem, not per event - see server/lib/eventLogFold.js.
import { foldEventFireAndForget } from '/server/lib/eventLogFold';
const { sanitizeDetail } = require('/models/lib/securityLogFormat');
// Record one FAILING test. Never throws.
// recordFailure({ suite:'unit', test:'tests/foo.test.cjs:does X', message:'expected a to equal b' })
export function recordFailure(evt = {}) {
try {
const sev = { security: 'high', e2e: 'low', unit: 'medium' }[evt.suite] || 'medium';
const doc = {
stream: 'tests',
at: new Date(),
severity: evt.severity || sev,
category: evt.category || 'test-failure',
bleed: evt.bleed || 'TestBleed',
action: evt.action || 'failed',
source: evt.test || evt.source || evt.suite || 'test',
cwe: '',
detail: sanitizeDetail(evt.message || evt.detail),
};
foldEventFireAndForget(doc, 'testLog');
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('testLog.recordFailure failed:', e && e.message);
}
}
export default { recordFailure };