All files / core/semantic-log/src normalize.ts

100% Statements 61/61
100% Branches 10/10
100% Functions 1/1
100% Lines 61/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 628x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 15017x 2x 2x 15015x 15017x 329x 329x 15017x 117488x 117488x 14686x 14686x  
/**
 * Variable masking (PRD R1).
 *
 * Order is significant: the ordered patterns must run most-specific first,
 * otherwise `<NUM>` would consume the digits inside a timestamp, a UUID, an IP
 * or a username and destroy the structure we are trying to preserve.
 */
 
const PATTERNS: ReadonlyArray<[RegExp, string]> = [
    // A home directory, in the spellings the three families write it:
    // /home/<user> (Linux, and every CI runner), /Users/<user> or /users/<user>
    // (macOS is case-insensitive) and C:\Users\<user> (Windows, drive letter
    // optional and either separator accepted). It runs before `<NUM>` so a
    // username that contains a digit is masked whole, and the lookbehind keeps
    // it to a home that opens a path: `proj/home/api` and `srv/users/data` are
    // ordinary directory names.
    [/(?<![\w.-])(?:[A-Za-z]:)?[\\/](?:Users|users|home)[\\/][A-Za-z0-9._-]+/g, '<HOME>'],
    // The root user's home carries no username segment to consume: in
    // /root/.blong the account directory is the one being masked, not a name
    // inside it.
    [/(?<![\w.-])[\\/]root(?=[\\/\s]|$)/g, '<HOME>'],
    // 2026-09-13T10:11:12.345Z / 2026-09-13 10:11:12
    [/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/g, '<TS>'],
    // 26-character Crockford base32 ULID: base32 minus I, L, O and U. The
    // letters J, K and M, N are written below as single-letter ranges so the
    // alphabet does not read as a misspelled word to the spell checker; the
    // set of accepted characters is unchanged.
    [/\b[0-9A-HJ-KM-NP-TV-Z]{26}\b/g, '<ULID>'],
    // 8-4-4-4-12 hex UUID
    [/\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g, '<UUID>'],
    // dotted quad
    [/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '<IP>'],
    // 0x-prefixed hex
    [/\b0x[0-9a-fA-F]+\b/g, '<HEX>'],
    // Any remaining number. No trailing `\b`: a quantity glued to its unit
    // (`5000ms`) must still mask, since the digits are the variable part.
    [/\b\d+/g, '<NUM>'],
];
 
/**
 * Reduce free text to its structural form by replacing variable values.
 *
 * Total over whatever a caller passes: a logging call must never throw, and the
 * message reaches here from a call site that may have handed over an object
 * rather than text — a pino-shaped consumer such as fastify logs the request
 * itself. Anything without a text form is coerced, so an unexpected message
 * degrades to a readable record instead of aborting the call that logged it.
 */
export function mask(text: unknown): string {
    if (text === undefined || text === null) {
        return '';
    }
    let out = String(text);
    if (!out) {
        return '';
    }
    for (const [pattern, token] of PATTERNS) {
        out = out.replace(pattern, token);
    }
    return out.replace(/\s+/g, ' ').trim();
}