From e53f70421609f0dee7d525b7175229ba8bea21e6 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Wed, 28 Oct 2026 15:34:42 +0200 Subject: [PATCH] fix(security): remove PII from Convex diagnostics Remove email, serialized auth-user and magic-link logging. Add a recursive AST regression guard across Convex TypeScript sources while retaining non-identifying diagnostics. Co-Authored-By: songscriptCodex running gpt-6.1-sol --- convex/admin.ts | 8 +- convex/betterAuth.ts | 7 +- convex/users.ts | 4 +- src/__tests__/convex-no-pii-logs.test.ts | 115 +++++++++++++++++++++++ 4 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/convex-no-pii-logs.test.ts diff --git a/convex/admin.ts b/convex/admin.ts index 0b767cb..143b6a2 100644 --- a/convex/admin.ts +++ b/convex/admin.ts @@ -15,7 +15,7 @@ import { v } from "convex/values"; export const deleteUserAndAllData = internalMutation({ args: { email: v.string() }, handler: async (ctx, args) => { - console.log(`[admin:deleteUserAndAllData] Starting deletion for user: ${args.email}`); + console.log("[admin:deleteUserAndAllData] Starting user deletion"); const deletionReport: Record = { wordProgress: 0, @@ -35,7 +35,7 @@ export const deleteUserAndAllData = internalMutation({ .first(); if (!appUser) { - console.log(`[admin:deleteUserAndAllData] User not found: ${args.email}`); + console.log("[admin:deleteUserAndAllData] User not found"); return { success: false, message: `User not found: ${args.email}`, @@ -44,10 +44,8 @@ export const deleteUserAndAllData = internalMutation({ } const userId = appUser._id; - const authId = appUser.authId; - console.log( - `[admin:deleteUserAndAllData] Found user - app userId: ${userId}, authId: ${authId}` + `[admin:deleteUserAndAllData] Found user - app userId: ${userId}` ); // Step 2: Delete all user-specific data in order diff --git a/convex/betterAuth.ts b/convex/betterAuth.ts index 65de19b..b5af48f 100644 --- a/convex/betterAuth.ts +++ b/convex/betterAuth.ts @@ -148,11 +148,6 @@ export const createAuth = (ctx: GenericCtx) => { // Build frontend verify URL - user goes directly to our app, no redirect through Convex const frontendVerifyUrl = `${frontendOrigin}/auth/verify?token=${token}`; - console.log("=".repeat(60)); - console.log("MAGIC LINK FOR:", email); - console.log("FRONTEND URL:", frontendVerifyUrl); - console.log("=".repeat(60)); - // Send email via Resend if configured if (resend) { try { @@ -319,4 +314,4 @@ export const createAuth = (ctx: GenericCtx) => { // handler: async (ctx) => { // return await authComponent.getAuthUser(ctx); // }, -// }); \ No newline at end of file +// }); diff --git a/convex/users.ts b/convex/users.ts index 4ad5529..75661a1 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -46,8 +46,6 @@ export const ensureAppUser = mutation({ throw new Error("Authentication required"); } - console.log("ensureAppUser: authUser =", JSON.stringify(authUser)); - // 1. Check if app user record already exists by authId const existingByAuthId = await ctx.db .query("users") @@ -85,7 +83,7 @@ export const ensureAppUser = mutation({ } // 3. Create new app user record with displayName from Better Auth - console.log("Creating new app user with email:", authUser.email, "authId:", authUser._id); + console.log("Creating new app user"); const userId = await ctx.db.insert("users", { email: authUser.email, authId: authUser._id, diff --git a/src/__tests__/convex-no-pii-logs.test.ts b/src/__tests__/convex-no-pii-logs.test.ts new file mode 100644 index 0000000..0c1cd09 --- /dev/null +++ b/src/__tests__/convex-no-pii-logs.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const convexRoot = fileURLToPath(new URL("../../convex/", import.meta.url)); +const logMethods = new Set(["log", "info", "warn", "error", "debug"]); +const sensitiveName = /email|token|url|authUser/i; +const userLikeName = /user|profile|account|name/i; + +function references(node: ts.Node, pattern: RegExp): boolean { + // Convex document IDs are permitted, even when the owner is an auth user. + if (ts.isPropertyAccessExpression(node) && node.name.text === "_id") { + return false; + } + return ( + (ts.isIdentifier(node) && pattern.test(node.text)) || + (ts.isElementAccessExpression(node) && + ts.isStringLiteral(node.argumentExpression) && + pattern.test(node.argumentExpression.text)) || + (ts.forEachChild(node, (child) => references(child, pattern) || undefined) ?? false) + ); +} + +function containsPii(node: ts.Node): boolean { + if (ts.isPropertyAccessExpression(node) && node.name.text === "_id") { + return false; + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "JSON" && + node.expression.name.text === "stringify" && + node.arguments.some((arg) => references(arg, userLikeName)) + ) { + return true; + } + return references(node, sensitiveName) || + (ts.forEachChild(node, (child) => containsPii(child) || undefined) ?? false); +} + +function piiLogLines(source: string): number[] { + const file = ts.createSourceFile("convex.ts", source, ts.ScriptTarget.Latest, true); + const lines: number[] = []; + function visit(node: ts.Node) { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const method = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ts.isElementAccessExpression(callee) && ts.isStringLiteral(callee.argumentExpression) + ? callee.argumentExpression.text + : undefined; + if ( + (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) && + ts.isIdentifier(callee.expression) && callee.expression.text === "console" && + method && logMethods.has(method) && node.arguments.some(containsPii) + ) { + lines.push(file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1); + } + } + ts.forEachChild(node, visit); + } + visit(file); + return lines; +} + +function convexFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + if (entry.name === "_generated") return []; + const path = join(directory, entry.name); + return entry.isDirectory() ? convexFiles(path) : entry.name.endsWith(".ts") ? [path] : []; + }); +} + +describe("Convex logs do not expose PII or authentication links", () => { + it.each([ + 'console.log("recipient", email)', + 'console.info(`recipient: ${args.email}`)', + 'console.warn("link", url)', + 'console.error("token", token)', + 'console.debug("URL", urlObj)', + 'console.log("link", verifyUrl)', + 'console.log(`link: ${frontendVerifyUrl}`)', + 'console.log("user", authUser)', + 'console.log("user", JSON.stringify(authUser))', + 'console.log("user", JSON.stringify(user))', + 'console.log("profile", JSON.stringify(profile))', + 'console.log("account", JSON.stringify({ name: displayName }))', + 'console.log(\n "recipient",\n args.email.toLowerCase()\n)', + 'console["log"]("recipient", args["email"])', + ])("detects sensitive arguments: %s", (source) => { + expect(piiLogLines(source)).toHaveLength(1); + }); + + it.each([ + 'console.log("Magic link email sent successfully via Resend")', + 'console.log("User created", userId)', + 'console.log("User found", existingByEmail._id)', + 'console.log("User found", authUser._id)', + 'console.log("Report", JSON.stringify(deletionReport))', + '// console.log(email)\nconst message = "console.log(token)"', + ])("allows non-identifying logs and ignores comments/strings: %s", (source) => { + expect(piiLogLines(source)).toEqual([]); + }); + + it.each(convexFiles(convexRoot).map((path) => [relative(convexRoot, path), path]))( + "%s has no sensitive console arguments", + (_name, path) => { + expect(piiLogLines(readFileSync(path, "utf8"))).toEqual([]); + }, + ); +});