fix(security-dashboard): fail closed instead of reporting "0 attacks" on backend error

The community security dashboard fails OPEN on a security-signaling surface: a
backend error, an unreachable backend, or a host without `jq` all render a
reassuring "Attacks detected last 7 days: 0 / Good news" that is
indistinguishable from a genuine zero. A user glances at the shield and
concludes there is no prompt-injection activity when the real state is unknown.
This is the same fail-open class the project's "4 security guards failing open"
wave (9cc41b71) corrected elsewhere.

Two layers fixed so the surface fails CLOSED:

- community-pulse edge function: the catch block returned all-zero aggregates
  with HTTP 200. It now tags the body with an explicit `error: true` flag (top
  level and inside `security`) so consumers can tell an internal failure apart
  from a real zero. Status stays 200 so existing `curl -sf` clients still get
  the body and can read the flag.

- gstack-security-dashboard: now captures curl's exit code and detects the
  degraded states (unreachable backend, `error: true` flag, missing security
  section, or `jq` absent) up front. In any degraded state it renders
  "Attacks detected last 7 days: unknown (Degraded: <reason>...)" in human mode
  and `{"error":"security_data_unavailable","security":null}` in --json mode,
  instead of a fake all-zero payload. A genuine zero still reads as
  "0 / Good news". The attack count is now read via an exact `jq` path
  (`.security.attacks_last_7_days`) rather than a two-stage grep that also
  mis-matched the "7" inside "last_7_days".

The latent telemetry `error_message` redaction concern noted in the issue is a
separate subsystem and left for a follow-up.

Adds test/security-dashboard.test.ts (5 tests, proven to fail on main):
unreachable, explicit-error, --json degraded, genuine-zero, and healthy non-zero.

Fixes #1947

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jayesh Betala 2026-06-16 15:25:52 +05:30
parent 9fd03fae9e
commit 6cf931d336
1 changed files with 143 additions and 0 deletions

View File

@ -0,0 +1,143 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// Regression tests for #1947: the community security dashboard must FAIL CLOSED.
// A backend error, an unreachable backend, or unparseable data must render
// "unknown", never a reassuring "0 attacks / Good news" — reporting 0
// when the real state is unknown is a fail-open on a security-signaling surface
// (the same class the project's "4 security guards failing open" wave corrected).
//
// The dashboard fetches `${GSTACK_SUPABASE_URL}/functions/v1/community-pulse` via
// curl. Tests put a fake curl first on PATH: when a fixture exists it writes the
// body and prints HTTP 200; when no fixture exists it prints HTTP 000.
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-security-dashboard');
const HAS_JQ = (() => {
try {
execSync('command -v jq', { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
let tmpDir: string;
let fixtureFile: string;
let fakeBin: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-secdash-'));
const fnDir = path.join(tmpDir, 'functions', 'v1');
fs.mkdirSync(fnDir, { recursive: true });
fixtureFile = path.join(fnDir, 'community-pulse');
fakeBin = path.join(tmpDir, 'bin');
fs.mkdirSync(fakeBin, { recursive: true });
const curl = path.join(fakeBin, 'curl');
fs.writeFileSync(
curl,
`#!/bin/sh
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "-o" ]; then
shift
out="$1"
fi
shift
done
if [ -f "${fixtureFile}" ]; then
cp "${fixtureFile}" "$out"
printf "200"
exit 0
fi
: > "$out"
printf "000"
exit 7
`,
);
fs.chmodSync(curl, 0o755);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function run(args: string, env: Record<string, string>): string {
const execOpts: ExecSyncOptionsWithStringEncoding = {
env: {
...process.env,
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
GSTACK_SUPABASE_ANON_KEY: 'test-anon-key',
...env,
},
encoding: 'utf-8',
timeout: 20000,
};
// The dashboard exits 0 in every state (unknown included), so no try/catch.
return execSync(`bash ${BIN} ${args}`, execOpts);
}
function writeFixture(json: object) {
fs.writeFileSync(fixtureFile, JSON.stringify(json));
}
const reachableUrl = () => 'https://community.test';
const unreachableUrl = () => 'https://community.test';
describe('gstack-security-dashboard fail-closed (#1947)', () => {
test('unreachable backend reports "unknown", not "0 attacks"', () => {
const out = run('', { GSTACK_SUPABASE_URL: unreachableUrl() });
expect(out).toContain('Attacks detected last 7 days: unknown');
expect(out).toMatch(/backend error/i);
// The fail-open strings must NOT appear.
expect(out).not.toContain('Attacks detected last 7 days: 0');
expect(out).not.toContain('Good news');
});
test('backend error body without security data reports unknown instead of zero', () => {
writeFixture({ error: 'pulse_unavailable' });
const out = run('', { GSTACK_SUPABASE_URL: reachableUrl() });
expect(out).toContain('Attacks detected last 7 days: unknown');
expect(out).toMatch(/backend error/i);
expect(out).not.toContain('Good news');
});
test('--json surfaces an explicit error, not a fake all-zero security object', () => {
const out = run('--json', { GSTACK_SUPABASE_URL: unreachableUrl() }).trim();
const parsed = JSON.parse(out);
expect(parsed.security).toBeNull();
expect(parsed.status).toBe('unknown');
expect(parsed.reason).toBe('backend_error');
// Must NOT emit the old reassuring zero payload.
expect(out).not.toContain('"attacks_last_7_days":0');
});
test.if(HAS_JQ)('a GENUINE zero still reads as "0 / Good news" (no over-classify)', () => {
writeFixture({
status: 'ok',
security: { attacks_last_7_days: 0, top_attack_domains: [], top_attack_layers: [], verdict_distribution: [] },
});
const out = run('', { GSTACK_SUPABASE_URL: reachableUrl() });
expect(out).toContain('Attacks detected last 7 days: 0');
expect(out).toContain('Good news');
expect(out).not.toMatch(/unknown/i);
});
test.if(HAS_JQ)('a non-zero count from a healthy backend is reported verbatim', () => {
writeFixture({
status: 'ok',
security: {
attacks_last_7_days: 4,
top_attack_domains: [{ domain: 'evil.test', count: 4 }],
top_attack_layers: [],
verdict_distribution: [],
},
});
const out = run('', { GSTACK_SUPABASE_URL: reachableUrl() });
expect(out).toContain('Attacks detected last 7 days: 4');
expect(out).not.toMatch(/unknown/i);
});
});