mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 17:05:28 +02:00
fix(security): community-pulse + both dashboards never report fake zeros (#1947)
The security-signaling surface failed open at three layers — every failure
mode read as a reassuring "0 attacks" / "0 installs":
- community-pulse edge function: supabase-js returns {data,error} without
throwing, and all five queries discarded `error` — a DB outage produced
real-looking zeros via the SUCCESS path, and the catch (also returning
zeros with HTTP 200) was unreachable for query failures. Every query now
destructures and throws; the catch serves the stale cache (marked
"stale": true) when one exists, else 503 {"error":"pulse_unavailable"}.
Success responses carry "status":"ok" so clients can distinguish
authoritative data from legacy backends. NOTE: the edge function deploys
out-of-band (supabase functions deploy community-pulse).
- gstack-security-dashboard: captures the HTTP status; non-200 / network
failure / error body / missing section → "unknown — backend error";
jq missing → "unknown — install jq" (the lossy grep fallback broke on
nested arrays and under-reported attacks as zero — removed); a 200
without the new marker shows figures with an "unverified (legacy
backend)" note. Also fixes a latent display bug: the TOTAL grep matched
the digit 7 inside "attacks_last_7_days" and misreported every count.
- gstack-community-dashboard: same class — curl || echo "{}" plus
grep || echo "0" printed "Weekly active installs: 0" on any failure.
Now "unknown — backend error (HTTP N)".
test/security-dashboard-fallback.test.ts pins the matrix (200+marker,
200-legacy, 503, network failure) x (jq present, jq absent) for both bins:
"unknown" states never render as 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
82c5140fda
commit
b3085f137e
@@ -2,54 +2,75 @@
|
||||
// Returns aggregated community stats for the dashboard:
|
||||
// weekly active count, top skills, crash clusters, version distribution.
|
||||
// Uses server-side cache (community_pulse_cache table) to prevent DoS.
|
||||
//
|
||||
// Fail-closed contract (#1947): success responses carry `status: "ok"` so
|
||||
// clients can distinguish authoritative data from legacy responses. Errors
|
||||
// NEVER masquerade as healthy zeros — the catch serves a stale cache (marked
|
||||
// `stale: true`) when one exists, else 503 {"error":"pulse_unavailable"}.
|
||||
// supabase-js does not throw on query failure, so every query destructures
|
||||
// `error` and throws explicitly; previously errors were discarded and `?? 0`
|
||||
// turned outages into fake zeros via the success path.
|
||||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const JSON_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
};
|
||||
|
||||
Deno.serve(async () => {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL") ?? "",
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
|
||||
);
|
||||
|
||||
// Cache fetch is hoisted above the recompute try so the catch can serve a
|
||||
// stale-but-real snapshot instead of an error when recompute fails.
|
||||
let cached: { data: Record<string, unknown>; refreshed_at: string } | null = null;
|
||||
try {
|
||||
// Check cache first
|
||||
const { data: cached } = await supabase
|
||||
const { data } = await supabase
|
||||
.from("community_pulse_cache")
|
||||
.select("data, refreshed_at")
|
||||
.eq("id", 1)
|
||||
.single();
|
||||
cached = data ?? null;
|
||||
} catch {
|
||||
cached = null; // cache miss/failure is non-fatal — recompute decides
|
||||
}
|
||||
|
||||
if (cached?.refreshed_at) {
|
||||
const age = Date.now() - new Date(cached.refreshed_at).getTime();
|
||||
if (age < CACHE_MAX_AGE_MS) {
|
||||
return new Response(JSON.stringify(cached.data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (cached?.refreshed_at) {
|
||||
const age = Date.now() - new Date(cached.refreshed_at).getTime();
|
||||
if (age < CACHE_MAX_AGE_MS) {
|
||||
// Serving the cache means this (new) backend is healthy; assert the
|
||||
// marker even for blobs cached by older code.
|
||||
return new Response(JSON.stringify({ ...cached.data, status: "ok" }), {
|
||||
status: 200,
|
||||
headers: JSON_HEADERS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Cache is stale or missing — recompute
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// Weekly active (update checks this week)
|
||||
const { count: thisWeek } = await supabase
|
||||
const { count: thisWeek, error: thisWeekErr } = await supabase
|
||||
.from("update_checks")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("checked_at", weekAgo);
|
||||
if (thisWeekErr) throw thisWeekErr;
|
||||
|
||||
// Last week (for change %)
|
||||
const { count: lastWeek } = await supabase
|
||||
const { count: lastWeek, error: lastWeekErr } = await supabase
|
||||
.from("update_checks")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("checked_at", twoWeeksAgo)
|
||||
.lt("checked_at", weekAgo);
|
||||
if (lastWeekErr) throw lastWeekErr;
|
||||
|
||||
const current = thisWeek ?? 0;
|
||||
const previous = lastWeek ?? 0;
|
||||
@@ -58,13 +79,14 @@ Deno.serve(async () => {
|
||||
: 0;
|
||||
|
||||
// Top skills (last 7 days)
|
||||
const { data: skillRows } = await supabase
|
||||
const { data: skillRows, error: skillErr } = await supabase
|
||||
.from("telemetry_events")
|
||||
.select("skill")
|
||||
.eq("event_type", "skill_run")
|
||||
.gte("event_timestamp", weekAgo)
|
||||
.not("skill", "is", null)
|
||||
.limit(1000);
|
||||
if (skillErr) throw skillErr;
|
||||
|
||||
const skillCounts: Record<string, number> = {};
|
||||
for (const row of skillRows ?? []) {
|
||||
@@ -78,19 +100,21 @@ Deno.serve(async () => {
|
||||
.map(([skill, count]) => ({ skill, count }));
|
||||
|
||||
// Crash clusters (top 5)
|
||||
const { data: crashes } = await supabase
|
||||
const { data: crashes, error: crashErr } = await supabase
|
||||
.from("crash_clusters")
|
||||
.select("error_class, gstack_version, total_occurrences, identified_users")
|
||||
.limit(5);
|
||||
if (crashErr) throw crashErr;
|
||||
|
||||
// Version distribution (last 7 days)
|
||||
const versionCounts: Record<string, number> = {};
|
||||
const { data: versionRows } = await supabase
|
||||
const { data: versionRows, error: versionErr } = await supabase
|
||||
.from("telemetry_events")
|
||||
.select("gstack_version")
|
||||
.eq("event_type", "skill_run")
|
||||
.gte("event_timestamp", weekAgo)
|
||||
.limit(1000);
|
||||
if (versionErr) throw versionErr;
|
||||
|
||||
for (const row of versionRows ?? []) {
|
||||
if (row.gstack_version) {
|
||||
@@ -106,12 +130,13 @@ Deno.serve(async () => {
|
||||
// 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
|
||||
const { data: attackRows, error: attackErr } = await supabase
|
||||
.from("telemetry_events")
|
||||
.select("security_url_domain, security_layer, security_verdict, installation_id")
|
||||
.eq("event_type", "attack_attempt")
|
||||
.gte("event_timestamp", weekAgo)
|
||||
.limit(5000);
|
||||
if (attackErr) throw attackErr;
|
||||
|
||||
// k-anonymity threshold. A domain (or layer) must be reported by at least
|
||||
// K_ANON distinct installations to appear in the aggregate. Without this,
|
||||
@@ -161,6 +186,7 @@ Deno.serve(async () => {
|
||||
.map(([verdict, count]) => ({ verdict, count }));
|
||||
|
||||
const result = {
|
||||
status: "ok",
|
||||
weekly_active: current,
|
||||
change_pct: changePct,
|
||||
top_skills: topSkills,
|
||||
@@ -186,30 +212,21 @@ Deno.serve(async () => {
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
headers: JSON_HEADERS,
|
||||
});
|
||||
} catch {
|
||||
return new Response(
|
||||
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" },
|
||||
}
|
||||
);
|
||||
// Recompute failed. A stale snapshot of real data beats an error — and
|
||||
// both beat fake zeros, which are indistinguishable from a healthy
|
||||
// "no attacks" reading on a security surface.
|
||||
if (cached?.data) {
|
||||
return new Response(
|
||||
JSON.stringify({ ...cached.data, status: "ok", stale: true }),
|
||||
{ status: 200, headers: JSON_HEADERS },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "pulse_unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user