From 6e25ab22c43a531db5f61fff2ea9dcd8a2356cd7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 16 Aug 2026 10:03:49 +0000 Subject: [PATCH 1/2] fix(spam): require a corroborating signal before entropy flags a username Shannon entropy scales with length and character diversity, so long descriptive usernames scored as high as generated ones and were auto-suspended on signup with no other signal. "vianerds_scoutworkshop" measured 3.94 against a 3.8 threshold; 143 profiles tripped the rule and 37 of them were readable service names (workbuddy-agent-v2, hermes_autonomous_agent_090334, sokol-data-pipeline-2026). Entropy now needs at least one corroborating randomness signal. The corroborators are weaker than the standalone rules and never flag on their own -- they only gate the entropy branch. Generated strings are unbroken tokens that are consonant-heavy, randomly cased, or carry capital runs; names a human picked use separators or read as words. 'y' is excluded from consonant runs as a semivowel, and the run threshold is 6, because real compounds reach 5 ("northstar" -> "rthst"). Also realigns spam-check.ts with the SQL thresholds it mirrors -- it had drifted to >12/>4.0 while check_username_spam() used >10/>3.8 -- so signup and the is_spam trigger agree. Co-Authored-By: Claude Opus 5 --- src/lib/spam-check.test.ts | 30 ++++ src/lib/spam-check.ts | 57 ++++++- ...6120000_entropy_requires_corroboration.sql | 139 ++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260816120000_entropy_requires_corroboration.sql diff --git a/src/lib/spam-check.test.ts b/src/lib/spam-check.test.ts index 2667c2e2..f2e9890a 100644 --- a/src/lib/spam-check.test.ts +++ b/src/lib/spam-check.test.ts @@ -29,6 +29,36 @@ describe("checkSpam", () => { }); }); + describe("high-entropy usernames need a corroborating signal", () => { + // Entropy scales with length and character diversity, so descriptive names + // score as high as generated ones. All of these measure above the 3.8 + // threshold and must stay clear on entropy alone. + it.each([ + "vianerds_scoutworkshop", + "workbuddy-agent-v2", + "hermes_autonomous_agent_090334", + "sokol-data-pipeline-2026", + "hiveadvise-runner-db9c06", + "tampahomeworks_f103f", + "WatchingMyHuman_AI", + "northstar-evidence-feb1", + "VantagexAdvisory", + "orbitopenclaw2026", + ])("allows descriptive %s", (username) => { + expect(checkSpam(username).spam).toBe(false); + }); + + it.each([ + ["OisIHtXmpaUjTVzPmY", "random case switching"], + ["BlQyGebwabqMZMmdOp", "consonant-heavy token"], + ["xgZbdpNuOclqewkr", "consonant-heavy token"], + ["gfrGEwzqIEDBENAVUhm", "capital run mid-token"], + ["termux_agent_pzdrhklp", "unpronounceable cluster despite separator"], + ])("blocks generated %s (%s)", (username) => { + expect(checkSpam(username).spam).toBe(true); + }); + }); + describe("clean names", () => { it.each([ "Anthony Ettinger", diff --git a/src/lib/spam-check.ts b/src/lib/spam-check.ts index 7f54a015..2917e122 100644 --- a/src/lib/spam-check.ts +++ b/src/lib/spam-check.ts @@ -47,6 +47,52 @@ function shannonEntropy(str: string): number { }, 0); } +// Corroborating randomness signals for the entropy check. Each is deliberately +// weaker than the standalone patterns above and is never used on its own -- they +// only gate the entropy branch. Generated strings are unbroken tokens that are +// consonant-heavy, randomly cased, or carry capital runs; names a human picked use +// separators or read as words. +function looksGenerated(str: string): boolean { + const hasSeparator = /[_.\-]/.test(str); + const lower = str.toLowerCase(); + const letters = lower.replace(/[^a-z]/g, ""); + const vowelRatio = letters.length + ? letters.replace(/[^aeiou]/g, "").length / letters.length + : 1; + + // Case flips between adjacent letters: random generators flip constantly, + // CamelCase flips once per word. + let caseSwitches = 0; + for (let i = 1; i < str.length; i++) { + const prev = str[i - 1]; + const curr = str[i]; + if ( + /[a-zA-Z]/.test(prev) && + /[a-zA-Z]/.test(curr) && + (prev === prev.toUpperCase()) !== (curr === curr.toUpperCase()) + ) { + caseSwitches++; + } + } + const caseSwitchRatio = caseSwitches / str.length; + + const longestRun = (s: string, re: RegExp) => + (s.match(re) ?? []).reduce((max, run) => Math.max(max, run.length), 0); + // Run over the full string so separators and digits break clusters, matching + // check_username_spam() in SQL. 'y' is excluded as a semivowel: counting it turns + // readable compounds into false clusters ("watchingmyhuman" -> "ngmyh"). + const maxConsonantRun = longestRun(lower, /[bcdfghjklmnpqrstvwxz]+/g); + const maxUpperRun = longestRun(str, /[A-Z]+/g); + + return ( + (!hasSeparator && caseSwitchRatio > 0.25) || + (!hasSeparator && vowelRatio < 0.25) || + // 6 rather than 5 because real compounds reach 5 ("northstar" -> "rthst"). + maxConsonantRun >= 6 || + (!hasSeparator && /[a-z]/.test(str) && maxUpperRun >= 3) + ); +} + export function checkSpam( username: string, fullName?: string | null @@ -62,8 +108,15 @@ export function checkSpam( return { spam: true, reason: "Username appears to be random characters" }; } - // High entropy + long username = likely random/bot - if (username.length > 12 && shannonEntropy(username) > 4.0) { + // High entropy on its own is not evidence of a bot: entropy grows with length and + // character diversity, so a long descriptive name scores as high as a generated + // one. Require a corroborating randomness signal. Thresholds match + // check_username_spam() in SQL, which is what actually sets profiles.is_spam. + if ( + username.length > 10 && + shannonEntropy(username) > 3.8 && + looksGenerated(username) + ) { return { spam: true, reason: "Username appears randomly generated" }; } diff --git a/supabase/migrations/20260816120000_entropy_requires_corroboration.sql b/supabase/migrations/20260816120000_entropy_requires_corroboration.sql new file mode 100644 index 00000000..23a2ba19 --- /dev/null +++ b/supabase/migrations/20260816120000_entropy_requires_corroboration.sql @@ -0,0 +1,139 @@ +-- Entropy alone is no longer grounds to flag a username as spam. +-- +-- Shannon entropy scales with length and character diversity, so long *descriptive* +-- usernames score as high as random ones. "vianerds_scoutworkshop" measured 3.94 +-- against a 3.8 threshold and was auto-suspended on signup with no other signal. +-- 143 profiles tripped the entropy rule; 32 of them were readable service names +-- (workbuddy-agent-v2, hermes_autonomous_agent_090334, sokol-data-pipeline-2026). +-- +-- The rule now requires entropy AND at least one corroborating randomness signal. +-- The corroborators are deliberately weaker than the standalone rules above them and +-- can never flag on their own -- they only gate the entropy branch. Real generated +-- strings are unbroken tokens (no separator) that are consonant-heavy, randomly +-- cased, or contain capital runs; descriptive names use separators or read as words. + +CREATE OR REPLACE FUNCTION check_username_spam(uname text, fname text DEFAULT NULL) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +DECLARE + lower_uname text; + letters text; + vowel_count int; + entropy float; + i int; + counts int[256]; + p float; + has_separator boolean; + vowel_ratio float; + case_switches int; + case_switch_ratio float; + max_consonant_run int; + max_upper_run int; + has_lower boolean; + corroborated boolean; +BEGIN + IF uname IS NULL THEN RETURN false; END IF; + lower_uname := lower(uname); + + -- Username spam patterns (unchanged) + IF lower_uname ~ '^[a-z]{2,4}\d{5,}$' THEN RETURN true; END IF; + IF lower_uname ~ '^user\d{4,}$' THEN RETURN true; END IF; + IF lower_uname ~ '^[a-z]+_[a-z]+\d{3,}$' THEN RETURN true; END IF; + IF uname ~ '\d{8,}' THEN RETURN true; END IF; + IF lower_uname ~ '^[a-z0-9]{20,}$' THEN RETURN true; END IF; + IF uname ~ '(.)\1{4,}' THEN RETURN true; END IF; + IF lower_uname ~ '^(buy|sell|cheap|free|promo|discount|crypto|nft|airdrop|casino|poker|viagra|cialis)' THEN RETURN true; END IF; + IF lower_uname ~ '(seo|marketing|agency|boost|traffic|followers|likes)\d*$' THEN RETURN true; END IF; + + -- Mixed-case random: 14+ chars of only letters with lots of case switches (unchanged) + IF uname ~ '^[a-zA-Z]{14,}$' THEN + DECLARE + switches int := 0; + prev_upper boolean; + curr_upper boolean; + BEGIN + prev_upper := ascii(substr(uname, 1, 1)) BETWEEN 65 AND 90; + FOR i IN 2..length(uname) LOOP + curr_upper := ascii(substr(uname, i, 1)) BETWEEN 65 AND 90; + IF curr_upper != prev_upper THEN switches := switches + 1; END IF; + prev_upper := curr_upper; + END LOOP; + IF switches::float / length(uname) > 0.3 THEN RETURN true; END IF; + END; + END IF; + + -- Keyboard mash: long string with very few vowels (unchanged) + letters := lower(regexp_replace(uname, '[^a-zA-Z]', '', 'g')); + IF length(letters) > 8 THEN + vowel_count := length(regexp_replace(letters, '[^aeiou]', '', 'g')); + IF vowel_count::float / length(letters) < 0.15 THEN RETURN true; END IF; + END IF; + + -- Shannon entropy -- now requires a corroborating randomness signal. + IF length(uname) > 10 THEN + counts := array_fill(0, ARRAY[256]); + FOR i IN 1..length(uname) LOOP + counts[ascii(substr(uname, i, 1)) + 1] := counts[ascii(substr(uname, i, 1)) + 1] + 1; + END LOOP; + entropy := 0; + FOR i IN 1..256 LOOP + IF counts[i] > 0 THEN + p := counts[i]::float / length(uname); + entropy := entropy - p * (ln(p) / ln(2)); + END IF; + END LOOP; + + IF entropy > 3.8 THEN + -- Separators (and readable compounds) mark a name a human chose. + has_separator := uname ~ '[_.\-]'; + + vowel_ratio := CASE WHEN length(letters) = 0 THEN 1 + ELSE length(regexp_replace(letters, '[^aeiou]', '', 'g'))::float / length(letters) END; + + -- Case flips between adjacent letters; random generators flip constantly, + -- CamelCase flips once per word. + case_switches := 0; + FOR i IN 2..length(uname) LOOP + IF substr(uname, i, 1) ~ '[a-zA-Z]' AND substr(uname, i - 1, 1) ~ '[a-zA-Z]' + AND (ascii(substr(uname, i, 1)) BETWEEN 65 AND 90) + IS DISTINCT FROM (ascii(substr(uname, i - 1, 1)) BETWEEN 65 AND 90) + THEN + case_switches := case_switches + 1; + END IF; + END LOOP; + case_switch_ratio := case_switches::float / length(uname); + + -- 'y' is excluded as a semivowel: counting it turns readable compounds into + -- false clusters ("watchingmyhuman" -> "ngmyh"). 6 rather than 5 because real + -- compounds reach 5 ("northstar" -> "rthst"). + max_consonant_run := coalesce( + (SELECT max(length(x[1])) FROM regexp_matches(lower_uname, '[bcdfghjklmnpqrstvwxz]+', 'g') x), 0); + max_upper_run := coalesce( + (SELECT max(length(x[1])) FROM regexp_matches(uname, '[A-Z]+', 'g') x), 0); + has_lower := uname ~ '[a-z]'; + + corroborated := + (NOT has_separator AND case_switch_ratio > 0.25) -- randomly cased token + OR (NOT has_separator AND vowel_ratio < 0.25) -- consonant-heavy token + OR (max_consonant_run >= 6) -- unpronounceable cluster + OR (NOT has_separator AND has_lower AND max_upper_run >= 3); -- capital run mid-token + + IF corroborated THEN RETURN true; END IF; + END IF; + END IF; + + -- Name spam patterns (unchanged) + IF fname IS NOT NULL THEN + IF fname ~ '(.)\1{3,}' THEN RETURN true; END IF; + IF fname ~ '\d{4,}' THEN RETURN true; END IF; + IF fname !~ '[a-zA-Z]' THEN RETURN true; END IF; + IF fname ~* '(http|www\.|\.com|\.net|\.org)' THEN RETURN true; END IF; + IF fname ~* '^(admin|moderator|support|helpdesk|official)' THEN RETURN true; END IF; + END IF; + + RETURN false; +END; +$$; + +-- Re-backfill so accounts flagged by entropy alone are released. +UPDATE profiles SET is_spam = check_username_spam(username, full_name) +WHERE is_spam IS DISTINCT FROM check_username_spam(username, full_name); From ad35c94603fbc898a364d779ee50d1414059e9ce Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 16 Aug 2026 10:38:43 +0000 Subject: [PATCH 2/2] fix(spam): don't let the mixed-case rule fire on deliberate CamelCase Same flaw the entropy rule had: a shape heuristic firing alone on a name users pick on purpose. "14+ letters with a case-switch ratio above 0.3" targets generated strings, but CamelCase flips once per word and short words blow past the threshold -- "TheRealRiotCoder" scores 0.44 and was suspended for being spelled the way its owner spells it. Of the 153 names the rule caught, 10 were ordinary CamelCase (AdaLovelaceBot, SophiaElyaLabs, WatchingMyHuman, JeffGarroRojas). The rule now skips names that read as deliberate CamelCase: a capital followed by a lowercase run, repeated -- ^([A-Z][a-z]+)+$. Generated strings break that shape by starting lowercase ("eMoRPtApRJxcuiGD") or running capitals together ("OisIHtXmpaUjTVzPmY" -> "IH"). A vowel-ratio floor backs it up so a random string that happens to fit the shape is still caught. Verified against prod: 143 of 153 stay caught, 10 CamelCase names release, 0 accounts newly suspended. Co-Authored-By: Claude Opus 5 --- ...40000_mixedcase_requires_corroboration.sql | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 supabase/migrations/20260816140000_mixedcase_requires_corroboration.sql diff --git a/supabase/migrations/20260816140000_mixedcase_requires_corroboration.sql b/supabase/migrations/20260816140000_mixedcase_requires_corroboration.sql new file mode 100644 index 00000000..ccd8b1a3 --- /dev/null +++ b/supabase/migrations/20260816140000_mixedcase_requires_corroboration.sql @@ -0,0 +1,138 @@ +-- The mixed-case rule has the same flaw the entropy rule had: it fires alone on a +-- name shape that legitimate users pick deliberately. +-- +-- "14+ letters with a case-switch ratio above 0.3" is meant to catch generated +-- strings like "BlQyGebwabqMZMmdOp". But CamelCase flips case once per word, and with +-- short words that ratio is easily exceeded: "TheRealRiotCoder" scores 0.44 and was +-- suspended for being spelled the way its owner chose to spell it. Of the 153 names +-- the rule catches, 10 are ordinary CamelCase (AdaLovelaceBot, SophiaElyaLabs, +-- WatchingMyHuman, JeffGarroRojas). +-- +-- The rule now requires that the name NOT read as deliberate CamelCase. Clean +-- CamelCase is a capital followed by a lowercase run, repeated -- ^([A-Z][a-z]+)+$. +-- Generated strings break that shape: they start lowercase ("eMoRPtApRJxcuiGD") or +-- contain consecutive capitals ("OisIHtXmpaUjTVzPmY" -> "IH"). A vowel-ratio floor +-- backs it up, so a random string that happens to fit the shape is still caught. + +CREATE OR REPLACE FUNCTION check_username_spam(uname text, fname text DEFAULT NULL) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +DECLARE + lower_uname text; + letters text; + vowel_count int; + entropy float; + i int; + counts int[256]; + p float; + has_separator boolean; + vowel_ratio float; + case_switches int; + case_switch_ratio float; + max_consonant_run int; + max_upper_run int; + has_lower boolean; + corroborated boolean; +BEGIN + IF uname IS NULL THEN RETURN false; END IF; + lower_uname := lower(uname); + letters := lower(regexp_replace(uname, '[^a-zA-Z]', '', 'g')); + vowel_ratio := CASE WHEN length(letters) = 0 THEN 1 + ELSE length(regexp_replace(letters, '[^aeiou]', '', 'g'))::float / length(letters) END; + + -- Username spam patterns (unchanged) + IF lower_uname ~ '^[a-z]{2,4}\d{5,}$' THEN RETURN true; END IF; + IF lower_uname ~ '^user\d{4,}$' THEN RETURN true; END IF; + IF lower_uname ~ '^[a-z]+_[a-z]+\d{3,}$' THEN RETURN true; END IF; + IF uname ~ '\d{8,}' THEN RETURN true; END IF; + IF lower_uname ~ '^[a-z0-9]{20,}$' THEN RETURN true; END IF; + IF uname ~ '(.)\1{4,}' THEN RETURN true; END IF; + IF lower_uname ~ '^(buy|sell|cheap|free|promo|discount|crypto|nft|airdrop|casino|poker|viagra|cialis)' THEN RETURN true; END IF; + IF lower_uname ~ '(seo|marketing|agency|boost|traffic|followers|likes)\d*$' THEN RETURN true; END IF; + + -- Mixed-case random -- now skipped when the name reads as deliberate CamelCase. + IF uname ~ '^[a-zA-Z]{14,}$' + AND NOT (uname ~ '^([A-Z][a-z]+)+$' AND vowel_ratio >= 0.25) THEN + DECLARE + switches int := 0; + prev_upper boolean; + curr_upper boolean; + BEGIN + prev_upper := ascii(substr(uname, 1, 1)) BETWEEN 65 AND 90; + FOR i IN 2..length(uname) LOOP + curr_upper := ascii(substr(uname, i, 1)) BETWEEN 65 AND 90; + IF curr_upper != prev_upper THEN switches := switches + 1; END IF; + prev_upper := curr_upper; + END LOOP; + IF switches::float / length(uname) > 0.3 THEN RETURN true; END IF; + END; + END IF; + + -- Keyboard mash: long string with very few vowels (unchanged) + IF length(letters) > 8 THEN + vowel_count := length(regexp_replace(letters, '[^aeiou]', '', 'g')); + IF vowel_count::float / length(letters) < 0.15 THEN RETURN true; END IF; + END IF; + + -- Shannon entropy -- requires a corroborating randomness signal (20260816120000). + IF length(uname) > 10 THEN + counts := array_fill(0, ARRAY[256]); + FOR i IN 1..length(uname) LOOP + counts[ascii(substr(uname, i, 1)) + 1] := counts[ascii(substr(uname, i, 1)) + 1] + 1; + END LOOP; + entropy := 0; + FOR i IN 1..256 LOOP + IF counts[i] > 0 THEN + p := counts[i]::float / length(uname); + entropy := entropy - p * (ln(p) / ln(2)); + END IF; + END LOOP; + + IF entropy > 3.8 THEN + has_separator := uname ~ '[_.\-]'; + + case_switches := 0; + FOR i IN 2..length(uname) LOOP + IF substr(uname, i, 1) ~ '[a-zA-Z]' AND substr(uname, i - 1, 1) ~ '[a-zA-Z]' + AND (ascii(substr(uname, i, 1)) BETWEEN 65 AND 90) + IS DISTINCT FROM (ascii(substr(uname, i - 1, 1)) BETWEEN 65 AND 90) + THEN + case_switches := case_switches + 1; + END IF; + END LOOP; + case_switch_ratio := case_switches::float / length(uname); + + -- 'y' is excluded as a semivowel: counting it turns readable compounds into + -- false clusters ("watchingmyhuman" -> "ngmyh"). 6 rather than 5 because real + -- compounds reach 5 ("northstar" -> "rthst"). + max_consonant_run := coalesce( + (SELECT max(length(x[1])) FROM regexp_matches(lower_uname, '[bcdfghjklmnpqrstvwxz]+', 'g') x), 0); + max_upper_run := coalesce( + (SELECT max(length(x[1])) FROM regexp_matches(uname, '[A-Z]+', 'g') x), 0); + has_lower := uname ~ '[a-z]'; + + corroborated := + (NOT has_separator AND case_switch_ratio > 0.25) -- randomly cased token + OR (NOT has_separator AND vowel_ratio < 0.25) -- consonant-heavy token + OR (max_consonant_run >= 6) -- unpronounceable cluster + OR (NOT has_separator AND has_lower AND max_upper_run >= 3); -- capital run mid-token + + IF corroborated THEN RETURN true; END IF; + END IF; + END IF; + + -- Name spam patterns (unchanged) + IF fname IS NOT NULL THEN + IF fname ~ '(.)\1{3,}' THEN RETURN true; END IF; + IF fname ~ '\d{4,}' THEN RETURN true; END IF; + IF fname !~ '[a-zA-Z]' THEN RETURN true; END IF; + IF fname ~* '(http|www\.|\.com|\.net|\.org)' THEN RETURN true; END IF; + IF fname ~* '^(admin|moderator|support|helpdesk|official)' THEN RETURN true; END IF; + END IF; + + RETURN false; +END; +$$; + +-- Re-backfill so CamelCase names flagged by the mixed-case rule alone are released. +UPDATE profiles SET is_spam = check_username_spam(username, full_name) +WHERE is_spam IS DISTINCT FROM check_username_spam(username, full_name);