Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/lib/spam-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 55 additions & 2 deletions src/lib/spam-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" };
}

Expand Down
139 changes: 139 additions & 0 deletions supabase/migrations/20260816120000_entropy_requires_corroboration.sql
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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);
Loading