audit_log Leaks a Surname via a Reused ID-Mask Function
This article may contain affiliate links. Its content is not affected by advertising.
In short
A mask that keeps both ends of a hex identifier and hides only the middle covers almost the whole string on a 2-4 character Japanese name, so the surname stays exposed instead of masked.
Conclusion
A mask function written for identifiers like hex IDs or UUIDs — keep both ends visible, hide only the middle — stops working the moment it’s reused on a Japanese person’s name: on a short name, the kept portion covers almost the whole string. On a system-admin audit screen, a 2-4 character Japanese surname (or the full name) was shown as-is. A second leak sat in a separate path: the audit log’s full JSON view didn’t have name/contact keys on its redaction list at all, so the same category of data leaked there too. The fix was to add a name-only mask that keeps nothing, and a separate key pattern for the JSON path that catches name/contact fields and redacts them recursively.
Symptom
The system-admin memberships (user × class) list screen was designed to mask student/staff display names before returning them. The mask function in use was this one.
// mask.ts:26-33 (before)
/** Hides the middle of an identifier: "AA:BB:CC:DD:EE:FF" → "AA:…:FF" / "0123456789abcdef" → "0123…cdef". */
export function maskIdentifier(value: string): string {
if (value.length <= 8) {
// Short values keep only the first 2 chars (full redaction loses the ability
// to match rows; full display leaves the identifier intact).
return `${value.slice(0, 2)}${"•".repeat(Math.max(1, value.length - 2))}`;
}
return `${value.slice(0, 4)}…${value.slice(-4)}`;
}
The comment’s assumed input is a hex/UUID-style identifier such as mac, client, session, token, or device_id — for those, keeping the first 2 characters even on an 8-character-or-shorter value is enough to function as a mask. But this function wasn’t only called on identifiers.
// membership-list.ts:163-170 (before)
return {
// Raw displayName / userId are dropped here; only masked fields are returned (module doc: "PII").
rows: rows.map(({ displayName, userId, ...rest }) => ({
...rest,
userDisplayMasked: maskIdentifier(displayName),
userIdMasked: maskIdentifier(userId),
})),
The same maskIdentifier was also applied to student/staff displayName (a Japanese name). JavaScript string length counts each Japanese character as 1 regardless of its byte size, so a 4-character name falls into the “8 characters or shorter” branch and keeps its first 2 characters — usually the surname — untouched. A 2-character name keeps almost the entire name. The module’s own comment documented the resulting output as an example: “e.g. a surname followed by two bullets.”
Cause
maskIdentifier’s “keep both ends, hide the middle” design is sound for identifiers: hiding the middle of a hex string or UUID is enough to anonymize it, and keeping both ends still lets two values be visually matched on screen. The problem is that the function keeps a fixed number of characters regardless of the input’s length — a premise that holds for identifiers (assumed to be reasonably long) but breaks for a 2-4 character Japanese name.
A second leak sat in a different path entirely. maskJsonForDisplay, which converts audit-log JSON for on-screen display, decided what to redact based on key-name patterns — and that pattern only covered identifier-style keys.
// mask.ts (before)
const SENSITIVE_KEY_RE = /(mac|client|session|token|secret|credential|device_id|uid|user_id)/;
Keys like display_name, full_name, phone, and email don’t match this pattern, so they fell through to maskJsonForDisplay’s default branch, which merely truncates strings at 120 characters. Names and email addresses under 120 characters never got truncated, so any screen that prints the full JSON — such as audit_log.diff — showed them verbatim. The design had stopped at “redact identifier keys” and never added a separate list for “names and contact info are also PII.”
The fix
The existing maskIdentifier was left unchanged for its legitimate use on identifiers. A new, name-specific function was added, and the call site was switched to it.
// mask.ts:45-52 (after)
/**
* Display mask for person names (student/guardian/staff displayName, etc.). maskIdentifier is
* built for hex identifiers and leaks a surname (or the whole name on a 2-char name) on short
* Japanese names (ISSUE-2), so names keep neither characters nor length — a fixed placeholder.
* Row matching is done via the masked userId instead.
*/
export function maskPersonName(value: string | null | undefined): string {
return value ? "••" : "";
}
// membership-list.ts:168 (after)
userDisplayMasked: maskPersonName(displayName),
On the JSON-display side, a new regular expression detects name/contact-style keys and routes matches through a recursive full-redaction path instead.
// mask.ts:17-28 (after)
const NAME_KEY_RE =
/(display_?name|full_?name|first_?name|last_?name|family_?name|student_?name|parent_?name|guardian_?name|user_?name|nick_?name|kana|furigana|ruby|^name$|^names$|^students?$|^members?$|^attendees?$|roster|e_?mail|phone|^tel$|mobile|address|^addr|zip|postal|birth)/;
This key list intentionally excludes non-PII keys like schoolName or className — the design keeps audit usability (which school, which class an event belongs to stays readable) while specifically targeting names and contact info for redaction.
Preventing a repeat
The “keep both ends” design of this mask function was reasonable on its own, given an identifier as input. The failure happened the moment that function was reused on a different kind of input — a Japanese name — where neither the function’s name nor its type signature could tell you whether it was safe for that input. The parameter type value: string accepts a hex identifier and a Japanese name identically. When adding mask functions, the input needs to be split by whether it’s “an identifier that’s safe to assume is long enough” or “a human-readable value that can be short,” so the call site’s type or key name can mechanically prevent the wrong one from being reused.
Frequently asked questions
Q1Why was an identifier-masking function applied to a person's name at all?
maskIdentifier masks hex/UUID identifiers like mac, token, session, and user_id by keeping both ends and hiding the middle. The same function got reused for displayName on a membership list. That works for identifiers, but on a short Japanese name the kept portion covers almost the whole string.
Q2Concretely, how much of the name was exposed?
maskIdentifier kept only the first 2 characters for values 8 characters or shorter. Japanese names count 1 character per character in JS string length, so a 4-character name kept its first 2 (usually the surname), and a 2-character name kept almost the whole name.
Q3Why did the leak also show up in the audit log's JSON view, not just the display name?
maskJsonForDisplay, which converts jsonb for display, only masked identifier-style keys like mac, token, session, user_id. Name/contact keys like display_name, phone weren't on that list, so a screen printing full JSON, like audit_log.diff, showed names and emails under 120 chars verbatim.
Q4Was the fix to modify the existing maskIdentifier function?
No. maskIdentifier stayed unchanged for mac/UUID/token use. A new function, maskPersonName, keeps neither characters nor length, and the display-name call site switched to it. The JSON side got a NAME_KEY_RE pattern routing matching name/contact keys through recursive full redaction.
Environment verified
- Next.js ^16.0.0 / drizzle-orm ^0.45.2
- Found on 2026-06-12 during an Opus review of the system-admin audit screen (UIUX-03)
What this article is based on
- TypeScript file lines 26-33commit 4c23299
- TypeScript file lines 163-170commit 4c23299
- TypeScript file lines 17-28commit 85e8563
- TypeScript file lines 45-52commit 85e8563
- TypeScript file lines 164-170commit 85e8563
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.