diff --git a/supabase/functions/community-pulse/index.ts b/supabase/functions/community-pulse/index.ts index 682d3d3f..c4ecb3cb 100644 --- a/supabase/functions/community-pulse/index.ts +++ b/supabase/functions/community-pulse/index.ts @@ -108,31 +108,52 @@ Deno.serve(async () => { // security_layer, security_verdict. const { data: attackRows } = await supabase .from("telemetry_events") - .select("security_url_domain, security_layer, security_verdict") + .select("security_url_domain, security_layer, security_verdict, installation_id") .eq("event_type", "attack_attempt") .gte("event_timestamp", weekAgo) .limit(5000); + // k-anonymity threshold. A domain (or layer) must be reported by at least + // K_ANON distinct installations to appear in the aggregate. Without this, + // a single user's attack log leaks their targeted domains to every other + // gstack user who polls /community-pulse. With it, the dashboard shows + // only community-wide patterns. + const K_ANON = 5; + const attacksTotal = attackRows?.length ?? 0; const domainCounts: Record = {}; + const domainInstallations: Record> = {}; const layerCounts: Record = {}; + const layerInstallations: Record> = {}; const verdictCounts: Record = {}; for (const row of attackRows ?? []) { + const iid = row.installation_id ?? ""; if (row.security_url_domain) { domainCounts[row.security_url_domain] = (domainCounts[row.security_url_domain] ?? 0) + 1; + if (iid) { + (domainInstallations[row.security_url_domain] ??= new Set()).add(iid); + } } if (row.security_layer) { layerCounts[row.security_layer] = (layerCounts[row.security_layer] ?? 0) + 1; + if (iid) { + (layerInstallations[row.security_layer] ??= new Set()).add(iid); + } } if (row.security_verdict) { + // Verdict distribution is low-cardinality (block/warn/log_only) and + // aggregates population-wide with no re-identification risk, so no + // k-anon filter. verdictCounts[row.security_verdict] = (verdictCounts[row.security_verdict] ?? 0) + 1; } } const topAttackDomains = Object.entries(domainCounts) + .filter(([domain]) => (domainInstallations[domain]?.size ?? 0) >= K_ANON) .sort(([, a], [, b]) => b - a) .slice(0, 10) .map(([domain, count]) => ({ domain, count })); const topAttackLayers = Object.entries(layerCounts) + .filter(([layer]) => (layerInstallations[layer]?.size ?? 0) >= K_ANON) .sort(([, a], [, b]) => b - a) .map(([layer, count]) => ({ layer, count })); const attackVerdictDistribution = Object.entries(verdictCounts)