feat(supabase): community-pulse aggregates attack telemetry

Adds a `security` section to the community-pulse response:

  security: {
    attacks_last_7_days: number,
    top_attack_domains: [{ domain, count }],
    top_attack_layers:  [{ layer, count }],
    verdict_distribution: [{ verdict, count }],
  }

Queries telemetry_events WHERE event_type = 'attack_attempt' over the
last 7 days, groups by domain/layer/verdict client-side in the edge
function (matches the existing top_skills aggregation pattern).

Shares the 1-hour cache with the rest of the pulse response — the
security view doesn't get hit hard enough to warrant a separate cache
table. Attack data updates once an hour for read-path consumers.

Fallback object (catch branch) includes empty security section so the
CLI consumer can render "no data yet" without branching on shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-20 04:58:08 +08:00
parent a5588ec061
commit 2d10797849
+57 -1
View File
@@ -102,12 +102,56 @@ Deno.serve(async () => {
.slice(0, 5)
.map(([version, count]) => ({ version, count }));
// Security events — aggregate attack_attempt events from the last 7 days.
// Fields emitted by gstack-telemetry-log --event-type attack_attempt:
// security_url_domain, security_payload_hash, security_confidence,
// security_layer, security_verdict.
const { data: attackRows } = await supabase
.from("telemetry_events")
.select("security_url_domain, security_layer, security_verdict")
.eq("event_type", "attack_attempt")
.gte("event_timestamp", weekAgo)
.limit(5000);
const attacksTotal = attackRows?.length ?? 0;
const domainCounts: Record<string, number> = {};
const layerCounts: Record<string, number> = {};
const verdictCounts: Record<string, number> = {};
for (const row of attackRows ?? []) {
if (row.security_url_domain) {
domainCounts[row.security_url_domain] = (domainCounts[row.security_url_domain] ?? 0) + 1;
}
if (row.security_layer) {
layerCounts[row.security_layer] = (layerCounts[row.security_layer] ?? 0) + 1;
}
if (row.security_verdict) {
verdictCounts[row.security_verdict] = (verdictCounts[row.security_verdict] ?? 0) + 1;
}
}
const topAttackDomains = Object.entries(domainCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([domain, count]) => ({ domain, count }));
const topAttackLayers = Object.entries(layerCounts)
.sort(([, a], [, b]) => b - a)
.map(([layer, count]) => ({ layer, count }));
const attackVerdictDistribution = Object.entries(verdictCounts)
.sort(([, a], [, b]) => b - a)
.map(([verdict, count]) => ({ verdict, count }));
const result = {
weekly_active: current,
change_pct: changePct,
top_skills: topSkills,
crashes: crashes ?? [],
versions: topVersions,
// Security aggregate for the /security-dashboard view
security: {
attacks_last_7_days: attacksTotal,
top_attack_domains: topAttackDomains,
top_attack_layers: topAttackLayers,
verdict_distribution: attackVerdictDistribution,
},
};
// Upsert cache
@@ -128,7 +172,19 @@ Deno.serve(async () => {
});
} catch {
return new Response(
JSON.stringify({ weekly_active: 0, change_pct: 0, top_skills: [], crashes: [], versions: [] }),
JSON.stringify({
weekly_active: 0,
change_pct: 0,
top_skills: [],
crashes: [],
versions: [],
security: {
attacks_last_7_days: 0,
top_attack_domains: [],
top_attack_layers: [],
verdict_distribution: [],
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },