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); 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);