mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-19 11:22:21 +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
@@ -31,20 +31,35 @@ if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── Fetch aggregated stats from edge function ────────────────
|
# ─── Fetch aggregated stats from edge function ────────────────
|
||||||
DATA="$(curl -sf --max-time 15 \
|
# HTTP status captured (#1947): a backend failure must read as "unknown",
|
||||||
|
# never as a healthy "Weekly active installs: 0".
|
||||||
|
TMPBODY="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMPBODY"' EXIT
|
||||||
|
HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
|
||||||
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
||||||
-H "apikey: ${ANON_KEY}" \
|
-H "apikey: ${ANON_KEY}" \
|
||||||
2>/dev/null || echo "{}")"
|
2>/dev/null || echo "000")"
|
||||||
|
DATA="$(cat "$TMPBODY" 2>/dev/null || echo "")"
|
||||||
|
|
||||||
echo "gstack community dashboard"
|
echo "gstack community dashboard"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "200" ] || [ -z "$DATA" ] || ! printf '%s' "$DATA" | grep -q '"weekly_active"'; then
|
||||||
|
echo "Community stats: unknown — backend error (HTTP ${HTTP_CODE})"
|
||||||
|
echo ""
|
||||||
|
echo "For local analytics: gstack-analytics"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# ─── Weekly active installs ──────────────────────────────────
|
# ─── Weekly active installs ──────────────────────────────────
|
||||||
WEEKLY="$(echo "$DATA" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")"
|
WEEKLY="$(echo "$DATA" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")"
|
||||||
CHANGE="$(echo "$DATA" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")"
|
CHANGE="$(echo "$DATA" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")"
|
||||||
|
|
||||||
echo "Weekly active installs: ${WEEKLY}"
|
echo "Weekly active installs: ${WEEKLY}"
|
||||||
|
if ! printf '%s' "$DATA" | grep -q '"status":"ok"'; then
|
||||||
|
echo " (unverified — legacy backend response; deploy the latest community-pulse for verified figures)"
|
||||||
|
fi
|
||||||
if [ "$CHANGE" -gt 0 ] 2>/dev/null; then
|
if [ "$CHANGE" -gt 0 ] 2>/dev/null; then
|
||||||
echo " Change: +${CHANGE}%"
|
echo " Change: +${CHANGE}%"
|
||||||
elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then
|
elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then
|
||||||
|
|||||||
@@ -41,28 +41,48 @@ if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DATA="$(curl -sf --max-time 15 \
|
# Fetch with the HTTP status captured (#1947). A backend failure must read
|
||||||
|
# as "unknown", never as a healthy "0 attacks" — fake zeros on a security
|
||||||
|
# surface are indistinguishable from good news.
|
||||||
|
TMPBODY="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMPBODY"' EXIT
|
||||||
|
HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
|
||||||
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
||||||
-H "apikey: ${ANON_KEY}" \
|
-H "apikey: ${ANON_KEY}" \
|
||||||
2>/dev/null || echo "{}")"
|
2>/dev/null || echo "000")"
|
||||||
|
DATA="$(cat "$TMPBODY" 2>/dev/null || echo "")"
|
||||||
|
|
||||||
# Extract the security section. Prefer jq for brace-balanced parsing of
|
# Classify the response:
|
||||||
# nested arrays/objects (top_attack_domains etc.). Fall back to regex if
|
# ok — 200 from the new backend (carries "status":"ok"); figures authoritative
|
||||||
# jq isn't installed — the regex is lossy but the dashboard degrades
|
# legacy — 200 with a security section but no marker (pre-#1947 backend);
|
||||||
# gracefully to "0 attacks" rather than misreporting numbers.
|
# figures shown but flagged unverified (old backend masked errors as zeros)
|
||||||
if command -v jq >/dev/null 2>&1; then
|
# unknown — non-200 / network failure / error body / missing section / no jq
|
||||||
SEC_SECTION="$(echo "$DATA" | jq -rc '.security // empty | "\"security\":\(.)"' 2>/dev/null || echo "")"
|
STATE="ok"
|
||||||
else
|
REASON=""
|
||||||
SEC_SECTION="$(echo "$DATA" | grep -o '"security":{[^}]*}' 2>/dev/null || echo "")"
|
if [ "$HTTP_CODE" != "200" ] || [ -z "$DATA" ]; then
|
||||||
|
STATE="unknown"; REASON="backend_error"
|
||||||
|
elif ! command -v jq >/dev/null 2>&1; then
|
||||||
|
# No lossy-grep fallback: the old regex broke on nested arrays and
|
||||||
|
# under-reported attacks as zero. Without jq the honest answer is unknown.
|
||||||
|
STATE="unknown"; REASON="jq_missing"
|
||||||
|
elif ! echo "$DATA" | jq -e '.security' >/dev/null 2>&1; then
|
||||||
|
STATE="unknown"; REASON="backend_error"
|
||||||
|
elif [ "$(echo "$DATA" | jq -r '.status // empty' 2>/dev/null)" != "ok" ]; then
|
||||||
|
STATE="legacy"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$JSON_MODE" = "1" ]; then
|
if [ "$JSON_MODE" = "1" ]; then
|
||||||
# Machine-readable — echo the whole security section (or empty object)
|
case "$STATE" in
|
||||||
if [ -n "$SEC_SECTION" ]; then
|
unknown)
|
||||||
echo "{${SEC_SECTION}}"
|
echo "{\"security\":null,\"status\":\"unknown\",\"reason\":\"${REASON}\"}"
|
||||||
else
|
;;
|
||||||
echo '{"security":{"attacks_last_7_days":0,"top_attack_domains":[],"top_attack_layers":[],"verdict_distribution":[]}}'
|
legacy)
|
||||||
fi
|
echo "$DATA" | jq -c '{security: .security, status: "legacy_unverified"}'
|
||||||
|
;;
|
||||||
|
ok)
|
||||||
|
echo "$DATA" | jq -c '{security: .security, status: "ok", stale: (.stale // false)}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -71,9 +91,26 @@ echo "gstack security dashboard"
|
|||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
TOTAL="$(echo "$DATA" | grep -o '"attacks_last_7_days":[0-9]*' | grep -o '[0-9]*' | head -1 || echo "0")"
|
if [ "$STATE" = "unknown" ]; then
|
||||||
|
if [ "$REASON" = "jq_missing" ]; then
|
||||||
|
echo "Attacks detected last 7 days: unknown — install jq for exact figures"
|
||||||
|
else
|
||||||
|
echo "Attacks detected last 7 days: unknown — backend error (HTTP ${HTTP_CODE})"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Your local log: ~/.gstack/security/attempts.jsonl"
|
||||||
|
echo "Your telemetry mode: $(${GSTACK_DIR}/bin/gstack-config get telemetry 2>/dev/null || echo unknown)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# jq is guaranteed here (jq-missing classified as unknown above). The old
|
||||||
|
# grep chain matched the digit 7 inside "attacks_last_7_days" itself and
|
||||||
|
# misreported every count as 7.
|
||||||
|
TOTAL="$(echo "$DATA" | jq -r '.security.attacks_last_7_days // 0' 2>/dev/null || echo "0")"
|
||||||
echo "Attacks detected last 7 days: ${TOTAL}"
|
echo "Attacks detected last 7 days: ${TOTAL}"
|
||||||
if [ "$TOTAL" = "0" ]; then
|
if [ "$STATE" = "legacy" ]; then
|
||||||
|
echo " (unverified — legacy backend response; deploy the latest community-pulse for verified figures)"
|
||||||
|
elif [ "$TOTAL" = "0" ]; then
|
||||||
echo " (No attack attempts reported by the community yet. Good news.)"
|
echo " (No attack attempts reported by the community yet. Good news.)"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -2,54 +2,75 @@
|
|||||||
// Returns aggregated community stats for the dashboard:
|
// Returns aggregated community stats for the dashboard:
|
||||||
// weekly active count, top skills, crash clusters, version distribution.
|
// weekly active count, top skills, crash clusters, version distribution.
|
||||||
// Uses server-side cache (community_pulse_cache table) to prevent DoS.
|
// 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";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
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 () => {
|
Deno.serve(async () => {
|
||||||
const supabase = createClient(
|
const supabase = createClient(
|
||||||
Deno.env.get("SUPABASE_URL") ?? "",
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
|
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 {
|
try {
|
||||||
// Check cache first
|
const { data } = await supabase
|
||||||
const { data: cached } = await supabase
|
|
||||||
.from("community_pulse_cache")
|
.from("community_pulse_cache")
|
||||||
.select("data, refreshed_at")
|
.select("data, refreshed_at")
|
||||||
.eq("id", 1)
|
.eq("id", 1)
|
||||||
.single();
|
.single();
|
||||||
|
cached = data ?? null;
|
||||||
|
} catch {
|
||||||
|
cached = null; // cache miss/failure is non-fatal — recompute decides
|
||||||
|
}
|
||||||
|
|
||||||
if (cached?.refreshed_at) {
|
if (cached?.refreshed_at) {
|
||||||
const age = Date.now() - new Date(cached.refreshed_at).getTime();
|
const age = Date.now() - new Date(cached.refreshed_at).getTime();
|
||||||
if (age < CACHE_MAX_AGE_MS) {
|
if (age < CACHE_MAX_AGE_MS) {
|
||||||
return new Response(JSON.stringify(cached.data), {
|
// Serving the cache means this (new) backend is healthy; assert the
|
||||||
status: 200,
|
// marker even for blobs cached by older code.
|
||||||
headers: {
|
return new Response(JSON.stringify({ ...cached.data, status: "ok" }), {
|
||||||
"Content-Type": "application/json",
|
status: 200,
|
||||||
"Cache-Control": "public, max-age=3600",
|
headers: JSON_HEADERS,
|
||||||
},
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Cache is stale or missing — recompute
|
// Cache is stale or missing — recompute
|
||||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
|
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
// Weekly active (update checks this week)
|
// Weekly active (update checks this week)
|
||||||
const { count: thisWeek } = await supabase
|
const { count: thisWeek, error: thisWeekErr } = await supabase
|
||||||
.from("update_checks")
|
.from("update_checks")
|
||||||
.select("*", { count: "exact", head: true })
|
.select("*", { count: "exact", head: true })
|
||||||
.gte("checked_at", weekAgo);
|
.gte("checked_at", weekAgo);
|
||||||
|
if (thisWeekErr) throw thisWeekErr;
|
||||||
|
|
||||||
// Last week (for change %)
|
// Last week (for change %)
|
||||||
const { count: lastWeek } = await supabase
|
const { count: lastWeek, error: lastWeekErr } = await supabase
|
||||||
.from("update_checks")
|
.from("update_checks")
|
||||||
.select("*", { count: "exact", head: true })
|
.select("*", { count: "exact", head: true })
|
||||||
.gte("checked_at", twoWeeksAgo)
|
.gte("checked_at", twoWeeksAgo)
|
||||||
.lt("checked_at", weekAgo);
|
.lt("checked_at", weekAgo);
|
||||||
|
if (lastWeekErr) throw lastWeekErr;
|
||||||
|
|
||||||
const current = thisWeek ?? 0;
|
const current = thisWeek ?? 0;
|
||||||
const previous = lastWeek ?? 0;
|
const previous = lastWeek ?? 0;
|
||||||
@@ -58,13 +79,14 @@ Deno.serve(async () => {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Top skills (last 7 days)
|
// Top skills (last 7 days)
|
||||||
const { data: skillRows } = await supabase
|
const { data: skillRows, error: skillErr } = await supabase
|
||||||
.from("telemetry_events")
|
.from("telemetry_events")
|
||||||
.select("skill")
|
.select("skill")
|
||||||
.eq("event_type", "skill_run")
|
.eq("event_type", "skill_run")
|
||||||
.gte("event_timestamp", weekAgo)
|
.gte("event_timestamp", weekAgo)
|
||||||
.not("skill", "is", null)
|
.not("skill", "is", null)
|
||||||
.limit(1000);
|
.limit(1000);
|
||||||
|
if (skillErr) throw skillErr;
|
||||||
|
|
||||||
const skillCounts: Record<string, number> = {};
|
const skillCounts: Record<string, number> = {};
|
||||||
for (const row of skillRows ?? []) {
|
for (const row of skillRows ?? []) {
|
||||||
@@ -78,19 +100,21 @@ Deno.serve(async () => {
|
|||||||
.map(([skill, count]) => ({ skill, count }));
|
.map(([skill, count]) => ({ skill, count }));
|
||||||
|
|
||||||
// Crash clusters (top 5)
|
// Crash clusters (top 5)
|
||||||
const { data: crashes } = await supabase
|
const { data: crashes, error: crashErr } = await supabase
|
||||||
.from("crash_clusters")
|
.from("crash_clusters")
|
||||||
.select("error_class, gstack_version, total_occurrences, identified_users")
|
.select("error_class, gstack_version, total_occurrences, identified_users")
|
||||||
.limit(5);
|
.limit(5);
|
||||||
|
if (crashErr) throw crashErr;
|
||||||
|
|
||||||
// Version distribution (last 7 days)
|
// Version distribution (last 7 days)
|
||||||
const versionCounts: Record<string, number> = {};
|
const versionCounts: Record<string, number> = {};
|
||||||
const { data: versionRows } = await supabase
|
const { data: versionRows, error: versionErr } = await supabase
|
||||||
.from("telemetry_events")
|
.from("telemetry_events")
|
||||||
.select("gstack_version")
|
.select("gstack_version")
|
||||||
.eq("event_type", "skill_run")
|
.eq("event_type", "skill_run")
|
||||||
.gte("event_timestamp", weekAgo)
|
.gte("event_timestamp", weekAgo)
|
||||||
.limit(1000);
|
.limit(1000);
|
||||||
|
if (versionErr) throw versionErr;
|
||||||
|
|
||||||
for (const row of versionRows ?? []) {
|
for (const row of versionRows ?? []) {
|
||||||
if (row.gstack_version) {
|
if (row.gstack_version) {
|
||||||
@@ -106,12 +130,13 @@ Deno.serve(async () => {
|
|||||||
// Fields emitted by gstack-telemetry-log --event-type attack_attempt:
|
// Fields emitted by gstack-telemetry-log --event-type attack_attempt:
|
||||||
// security_url_domain, security_payload_hash, security_confidence,
|
// security_url_domain, security_payload_hash, security_confidence,
|
||||||
// security_layer, security_verdict.
|
// security_layer, security_verdict.
|
||||||
const { data: attackRows } = await supabase
|
const { data: attackRows, error: attackErr } = await supabase
|
||||||
.from("telemetry_events")
|
.from("telemetry_events")
|
||||||
.select("security_url_domain, security_layer, security_verdict, installation_id")
|
.select("security_url_domain, security_layer, security_verdict, installation_id")
|
||||||
.eq("event_type", "attack_attempt")
|
.eq("event_type", "attack_attempt")
|
||||||
.gte("event_timestamp", weekAgo)
|
.gte("event_timestamp", weekAgo)
|
||||||
.limit(5000);
|
.limit(5000);
|
||||||
|
if (attackErr) throw attackErr;
|
||||||
|
|
||||||
// k-anonymity threshold. A domain (or layer) must be reported by at least
|
// k-anonymity threshold. A domain (or layer) must be reported by at least
|
||||||
// K_ANON distinct installations to appear in the aggregate. Without this,
|
// K_ANON distinct installations to appear in the aggregate. Without this,
|
||||||
@@ -161,6 +186,7 @@ Deno.serve(async () => {
|
|||||||
.map(([verdict, count]) => ({ verdict, count }));
|
.map(([verdict, count]) => ({ verdict, count }));
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
|
status: "ok",
|
||||||
weekly_active: current,
|
weekly_active: current,
|
||||||
change_pct: changePct,
|
change_pct: changePct,
|
||||||
top_skills: topSkills,
|
top_skills: topSkills,
|
||||||
@@ -186,30 +212,21 @@ Deno.serve(async () => {
|
|||||||
|
|
||||||
return new Response(JSON.stringify(result), {
|
return new Response(JSON.stringify(result), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: JSON_HEADERS,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Cache-Control": "public, max-age=3600",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(
|
// Recompute failed. A stale snapshot of real data beats an error — and
|
||||||
JSON.stringify({
|
// both beat fake zeros, which are indistinguishable from a healthy
|
||||||
weekly_active: 0,
|
// "no attacks" reading on a security surface.
|
||||||
change_pct: 0,
|
if (cached?.data) {
|
||||||
top_skills: [],
|
return new Response(
|
||||||
crashes: [],
|
JSON.stringify({ ...cached.data, status: "ok", stale: true }),
|
||||||
versions: [],
|
{ status: 200, headers: JSON_HEADERS },
|
||||||
security: {
|
);
|
||||||
attacks_last_7_days: 0,
|
}
|
||||||
top_attack_domains: [],
|
return new Response(JSON.stringify({ error: "pulse_unavailable" }), {
|
||||||
top_attack_layers: [],
|
status: 503,
|
||||||
verdict_distribution: [],
|
headers: { "Content-Type": "application/json" },
|
||||||
},
|
});
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* #1947 — security/community dashboards must never report fake zeros.
|
||||||
|
*
|
||||||
|
* A backend failure, a network failure, or a missing jq used to degrade to
|
||||||
|
* "0 attacks" / "Weekly active installs: 0" — indistinguishable from a
|
||||||
|
* genuinely healthy reading on a security-signaling surface. The contract
|
||||||
|
* pinned here:
|
||||||
|
*
|
||||||
|
* - non-200 / network failure / error body → "unknown", never 0
|
||||||
|
* - jq missing (security dashboard) → "unknown — install jq", never 0
|
||||||
|
* - 200 with the new backend's status:"ok" → figures trusted
|
||||||
|
* - 200 without the marker (legacy backend) → figures shown + "unverified" note
|
||||||
|
*
|
||||||
|
* curl is stubbed via a prepended PATH; the jq-missing case runs with a
|
||||||
|
* PATH containing only the stub + whitelisted tools (no jq).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..");
|
||||||
|
const SEC_BIN = join(ROOT, "bin", "gstack-security-dashboard");
|
||||||
|
const COMM_BIN = join(ROOT, "bin", "gstack-community-dashboard");
|
||||||
|
// Absolute path: the jq-missing case runs with a whitelist-only PATH, so
|
||||||
|
// "bash" itself wouldn't resolve through the child env.
|
||||||
|
const BASH = Bun.which("bash") || "/bin/bash";
|
||||||
|
|
||||||
|
const GOOD_BODY_MARKER = JSON.stringify({
|
||||||
|
status: "ok",
|
||||||
|
weekly_active: 42,
|
||||||
|
change_pct: 5,
|
||||||
|
top_skills: [{ skill: "ship", count: 9 }],
|
||||||
|
crashes: [],
|
||||||
|
versions: [],
|
||||||
|
security: {
|
||||||
|
attacks_last_7_days: 3,
|
||||||
|
top_attack_domains: [{ domain: "evil.example", count: 7 }],
|
||||||
|
top_attack_layers: [{ layer: "L4", count: 3 }],
|
||||||
|
verdict_distribution: [{ verdict: "block", count: 3 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-#1947 backend shape: same data, no status marker.
|
||||||
|
const GOOD_BODY_LEGACY = JSON.stringify({
|
||||||
|
...JSON.parse(GOOD_BODY_MARKER),
|
||||||
|
status: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CURL_STUB = `#!/bin/sh
|
||||||
|
# Test stub for curl: honors -o <file>, prints the HTTP code (as -w would).
|
||||||
|
out=""
|
||||||
|
prev=""
|
||||||
|
for a in "$@"; do
|
||||||
|
if [ "$prev" = "-o" ]; then out="$a"; fi
|
||||||
|
prev="$a"
|
||||||
|
done
|
||||||
|
case "\${STUB_CURL_MODE:-ok}" in
|
||||||
|
ok) [ -n "$out" ] && printf '%s' "$STUB_CURL_BODY" > "$out"; printf '200' ;;
|
||||||
|
error503) [ -n "$out" ] && printf '%s' '{"error":"pulse_unavailable"}' > "$out"; printf '503' ;;
|
||||||
|
netfail) exit 7 ;;
|
||||||
|
esac
|
||||||
|
`;
|
||||||
|
|
||||||
|
let tmp: string;
|
||||||
|
let stubBin: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmp = mkdtempSync(join(tmpdir(), "gstack-dash-test-"));
|
||||||
|
stubBin = join(tmp, "stub-bin");
|
||||||
|
mkdirSync(stubBin, { recursive: true });
|
||||||
|
writeFileSync(join(stubBin, "curl"), CURL_STUB);
|
||||||
|
chmodSync(join(stubBin, "curl"), 0o755);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function run(
|
||||||
|
bin: string,
|
||||||
|
opts: {
|
||||||
|
mode: "ok" | "error503" | "netfail";
|
||||||
|
body?: string;
|
||||||
|
json?: boolean;
|
||||||
|
noJq?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let pathEnv = `${stubBin}:${process.env.PATH || ""}`;
|
||||||
|
if (opts.noJq) {
|
||||||
|
// Whitelist-only PATH: the curl stub plus the real tools the script
|
||||||
|
// needs — everything except jq.
|
||||||
|
const toolBin = join(tmp, "tool-bin");
|
||||||
|
mkdirSync(toolBin, { recursive: true });
|
||||||
|
for (const tool of ["mktemp", "cat", "grep", "head", "sed", "awk", "rm", "sh", "bash"]) {
|
||||||
|
const real = Bun.which(tool);
|
||||||
|
if (real) symlinkSync(real, join(toolBin, tool));
|
||||||
|
}
|
||||||
|
pathEnv = `${stubBin}:${toolBin}`;
|
||||||
|
}
|
||||||
|
return spawnSync(BASH, opts.json ? [bin, "--json"] : [bin], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 20_000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: pathEnv,
|
||||||
|
GSTACK_DIR: ROOT,
|
||||||
|
GSTACK_SUPABASE_URL: "https://stub.supabase.test",
|
||||||
|
GSTACK_SUPABASE_ANON_KEY: "stub-key",
|
||||||
|
STUB_CURL_MODE: opts.mode,
|
||||||
|
STUB_CURL_BODY: opts.body ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gstack-security-dashboard — never reports fake zeros (#1947)", () => {
|
||||||
|
it("backend 503 → unknown, not 0 (human mode)", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "error503" });
|
||||||
|
expect(r.stdout).toContain("unknown — backend error (HTTP 503)");
|
||||||
|
expect(r.stdout).not.toContain("Attacks detected last 7 days: 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backend 503 → status unknown (json mode)", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "error503", json: true });
|
||||||
|
const parsed = JSON.parse(r.stdout.trim());
|
||||||
|
expect(parsed.status).toBe("unknown");
|
||||||
|
expect(parsed.reason).toBe("backend_error");
|
||||||
|
expect(parsed.security).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("network failure → unknown, not 0", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "netfail", json: true });
|
||||||
|
const parsed = JSON.parse(r.stdout.trim());
|
||||||
|
expect(parsed.status).toBe("unknown");
|
||||||
|
expect(parsed.security).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jq missing → unknown with install hint, never the lossy-grep zero", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_MARKER, noJq: true });
|
||||||
|
expect(r.stdout).toContain("unknown — install jq");
|
||||||
|
expect(r.stdout).not.toContain("Attacks detected last 7 days: 0");
|
||||||
|
expect(r.stdout).not.toContain("Attacks detected last 7 days: 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jq missing → reason jq_missing (json mode)", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_MARKER, noJq: true, json: true });
|
||||||
|
const parsed = JSON.parse(r.stdout.trim());
|
||||||
|
expect(parsed.status).toBe("unknown");
|
||||||
|
expect(parsed.reason).toBe("jq_missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("200 + status:ok marker → figures trusted (human mode)", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_MARKER });
|
||||||
|
expect(r.stdout).toContain("Attacks detected last 7 days: 3");
|
||||||
|
expect(r.stdout).toContain("evil.example");
|
||||||
|
expect(r.stdout).not.toContain("unverified");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("200 + status:ok marker → status ok with full security section (json mode)", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_MARKER, json: true });
|
||||||
|
const parsed = JSON.parse(r.stdout.trim());
|
||||||
|
expect(parsed.status).toBe("ok");
|
||||||
|
expect(parsed.security.attacks_last_7_days).toBe(3);
|
||||||
|
// Nested arrays survive (the old lossy-grep fallback broke on these).
|
||||||
|
expect(parsed.security.top_attack_domains[0].domain).toBe("evil.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("200 without marker (legacy backend) → figures shown with unverified note", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_LEGACY });
|
||||||
|
expect(r.stdout).toContain("Attacks detected last 7 days: 3");
|
||||||
|
expect(r.stdout).toContain("unverified");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("200 without marker → legacy_unverified (json mode)", () => {
|
||||||
|
const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_LEGACY, json: true });
|
||||||
|
const parsed = JSON.parse(r.stdout.trim());
|
||||||
|
expect(parsed.status).toBe("legacy_unverified");
|
||||||
|
expect(parsed.security.attacks_last_7_days).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gstack-community-dashboard — never reports fake zeros (#1947)", () => {
|
||||||
|
it("backend 503 → unknown, not 'Weekly active installs: 0'", () => {
|
||||||
|
const r = run(COMM_BIN, { mode: "error503" });
|
||||||
|
expect(r.stdout).toContain("unknown — backend error (HTTP 503)");
|
||||||
|
expect(r.stdout).not.toContain("Weekly active installs: 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("network failure → unknown, not 0", () => {
|
||||||
|
const r = run(COMM_BIN, { mode: "netfail" });
|
||||||
|
expect(r.stdout).toContain("unknown — backend error (HTTP 000)");
|
||||||
|
expect(r.stdout).not.toContain("Weekly active installs:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("200 + status:ok marker → figures shown without unverified note", () => {
|
||||||
|
const r = run(COMM_BIN, { mode: "ok", body: GOOD_BODY_MARKER });
|
||||||
|
expect(r.stdout).toContain("Weekly active installs: 42");
|
||||||
|
expect(r.stdout).not.toContain("unverified");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("200 without marker (legacy backend) → figures shown with unverified note", () => {
|
||||||
|
const r = run(COMM_BIN, { mode: "ok", body: GOOD_BODY_LEGACY });
|
||||||
|
expect(r.stdout).toContain("Weekly active installs: 42");
|
||||||
|
expect(r.stdout).toContain("unverified");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user