fix(auth): stop leaking usernames via emailDomain span attribute

getUserByUsername emitted the FULL username as the emailDomain span
attribute whenever the username contained no "@" (audit finding B6),
violating the PII-free telemetry rule (ADR-017 §7). Emit only a boolean
hasAtSign in both the production repository and its mock; regression
tests pin that no username-derived string reaches span attributes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:22:00 +02:00
parent 27787193c0
commit 2747feab46
3 changed files with 30 additions and 12 deletions

View File

@@ -48,7 +48,9 @@ export class MockUsersRepository implements IUsersRepository {
{
name: "users.getUserByUsername",
op: "repository",
attributes: { emailDomain: username.includes("@") ? (username.split("@")[1] ?? "(invalid)") : username },
// Never emit the username (or any slice of it) — it is PII and the
// non-email branch used to leak the full username (audit finding B6).
attributes: { hasAtSign: username.includes("@") },
},
async (span) => {
const found = this._users.find((u) => u.username === username);
@@ -60,7 +62,11 @@ export class MockUsersRepository implements IUsersRepository {
async createUser(input: User): Promise<User> {
return this.tracer.startSpan(
{ name: "users.createUser", op: "repository", attributes: { id: input.id } },
{
name: "users.createUser",
op: "repository",
attributes: { id: input.id },
},
async (span) => {
this._users.push(input);
span.setAttribute("created", true);

View File

@@ -21,17 +21,31 @@ describe("MockUsersRepository emits spans", () => {
expect(tracer.spans[0]!.attributes.found).toBe(false);
});
it("getUserByUsername emits a span with emailDomain attribute", async () => {
it("getUserByUsername emits a span without any username-derived PII", async () => {
const tracer = new RecordingTracer();
const repo = new MockUsersRepository(
[{ id: "1", username: "alice", passwordHash: "hash" }],
tracer,
);
await repo.getUserByUsername("alice");
expect(tracer.findSpan("users.getUserByUsername")).toBeDefined();
expect(tracer.findSpan("users.getUserByUsername")!.attributes.found).toBe(
true,
);
const span = tracer.findSpan("users.getUserByUsername");
expect(span).toBeDefined();
expect(span!.attributes.found).toBe(true);
// B6 regression guard: the old emailDomain attribute leaked the full
// username when it contained no "@". Only a boolean may be emitted.
expect(span!.attributes.emailDomain).toBeUndefined();
expect(span!.attributes.hasAtSign).toBe(false);
expect(Object.values(span!.attributes)).not.toContain("alice");
});
it("getUserByUsername with an email-shaped username emits only the boolean", async () => {
const tracer = new RecordingTracer();
const repo = new MockUsersRepository([], tracer);
await repo.getUserByUsername("alice@example.com");
const span = tracer.findSpan("users.getUserByUsername");
expect(span!.attributes.hasAtSign).toBe(true);
expect(span!.attributes.emailDomain).toBeUndefined();
expect(Object.values(span!.attributes)).not.toContain("example.com");
});
it("createUser records created=true", async () => {

View File

@@ -79,11 +79,9 @@ export class UsersRepository implements IUsersRepository {
{
name: "users.getUserByUsername",
op: "repository",
attributes: {
emailDomain: username.includes("@")
? (username.split("@")[1] ?? "(invalid)")
: username,
},
// Never emit the username (or any slice of it) — it is PII and the
// non-email branch used to leak the full username (audit finding B6).
attributes: { hasAtSign: username.includes("@") },
},
async (span) => {
try {