Skip to content

Commit 2212944

Browse files
authored
Merge pull request #415 from Depo-dev/feat/stellar-wave-observability-security-loadtest-runbook
Load/soak testing, security regression CI, observability, and ops docs
2 parents 9b56a63 + 81ceb28 commit 2212944

22 files changed

Lines changed: 1382 additions & 30 deletions
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Issue #385 — nightly WebSocket gateway load/soak test.
2+
#
3+
# Boots postgres + redis + two backend instances (ports 3001/3002) sharing
4+
# the same Redis, so fan-out and presence churn cross node boundaries via
5+
# @socket.io/redis-adapter. Seeds fixture data directly through Drizzle,
6+
# then runs scripts/loadtest/run.ts, which exits non-zero if latency,
7+
# memory, or error-rate thresholds (or the regression baseline in
8+
# scripts/loadtest/baseline.json) are violated.
9+
#
10+
# Runs nightly only — this is a soak test, not a per-PR gate. Trigger
11+
# manually via workflow_dispatch to validate before merging test changes.
12+
13+
name: Nightly Load Test
14+
15+
on:
16+
schedule:
17+
- cron: '0 3 * * *' # 03:00 UTC nightly
18+
workflow_dispatch:
19+
20+
jobs:
21+
loadtest:
22+
name: WebSocket gateway load/soak test
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 20
25+
26+
services:
27+
postgres:
28+
image: postgres:15-alpine
29+
env:
30+
POSTGRES_USER: postgres
31+
POSTGRES_PASSWORD: password
32+
POSTGRES_DB: clicked
33+
ports:
34+
- 5432:5432
35+
options: >-
36+
--health-cmd "pg_isready -U postgres -d clicked"
37+
--health-interval 5s
38+
--health-timeout 3s
39+
--health-retries 5
40+
redis:
41+
image: redis:7-alpine
42+
ports:
43+
- 6379:6379
44+
options: >-
45+
--health-cmd "redis-cli ping"
46+
--health-interval 5s
47+
--health-timeout 3s
48+
--health-retries 5
49+
50+
env:
51+
DATABASE_URL: postgres://postgres:password@localhost:5432/clicked
52+
REDIS_URL: redis://localhost:6379
53+
JWT_SECRET: loadtest-secret
54+
TOKEN_TRANSFER_CONTRACT_ID: loadtest-placeholder
55+
OBJECT_STORE_ENDPOINT: http://localhost:9000
56+
OBJECT_STORE_BUCKET: clicked
57+
OBJECT_STORE_ACCESS_KEY: clicked
58+
OBJECT_STORE_SECRET_KEY: clickedsecret
59+
OBJECT_STORE_REGION: us-east-1
60+
OBJECT_STORE_FORCE_PATH_STYLE: 'true'
61+
62+
steps:
63+
- name: Checkout
64+
uses: actions/checkout@v4
65+
66+
- name: Setup Node.js
67+
uses: actions/setup-node@v4
68+
with:
69+
node-version: '20'
70+
71+
- name: Install pnpm
72+
uses: pnpm/action-setup@v4
73+
74+
- name: Install dependencies
75+
run: pnpm install --frozen-lockfile
76+
77+
- name: Run DB migrations
78+
run: pnpm db:migrate
79+
working-directory: apps/backend
80+
81+
- name: Start backend node 1 (port 3001)
82+
run: pnpm dev &
83+
working-directory: apps/backend
84+
env:
85+
PORT: '3001'
86+
87+
- name: Start backend node 2 (port 3002)
88+
run: pnpm dev &
89+
working-directory: apps/backend
90+
env:
91+
PORT: '3002'
92+
93+
- name: Wait for both nodes to be healthy
94+
run: |
95+
for port in 3001 3002; do
96+
for i in $(seq 1 30); do
97+
if curl -sf "http://localhost:$port/health" > /dev/null; then
98+
echo "node on $port healthy"
99+
break
100+
fi
101+
sleep 1
102+
done
103+
done
104+
105+
- name: Seed load-test fixture (200 devices)
106+
run: npx tsx scripts/loadtest/seed.ts --devices 200 > fixture.json
107+
108+
- name: Run load/soak test
109+
run: >-
110+
npx tsx scripts/loadtest/run.ts
111+
--fixture fixture.json
112+
--nodes http://localhost:3001,http://localhost:3002
113+
--baseline scripts/loadtest/baseline.json
114+
--out loadtest-result.json
115+
116+
- name: Upload result
117+
if: always()
118+
uses: actions/upload-artifact@v4
119+
with:
120+
name: loadtest-result
121+
path: loadtest-result.json

.github/workflows/security-ci.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Issue #388 — Security regression checks.
2+
#
3+
# Runs on every PR and push to main. Two jobs:
4+
# - regression: the ciphertext-only guard and the private-key/session-state
5+
# field scan (apps/backend/src/__tests__/security.regression.test.ts).
6+
# - dependency-audit: `pnpm audit` scoped to the crypto-relevant backend
7+
# dependencies (ioredis, jsonwebtoken, web-push, @stellar/stellar-sdk,
8+
# drizzle-orm, socket.io), surfacing CVEs without failing on advisories
9+
# in unrelated transitive deps.
10+
11+
name: Security CI
12+
13+
on:
14+
push:
15+
branches: [main]
16+
pull_request:
17+
18+
jobs:
19+
regression:
20+
name: Ciphertext-only guard + secret-field scan
21+
runs-on: ubuntu-latest
22+
23+
defaults:
24+
run:
25+
working-directory: apps/backend
26+
27+
steps:
28+
- name: Checkout
29+
uses: actions/checkout@v4
30+
31+
- name: Setup Node.js
32+
uses: actions/setup-node@v4
33+
with:
34+
node-version: '20'
35+
36+
- name: Install pnpm
37+
uses: pnpm/action-setup@v4
38+
39+
- name: Install dependencies
40+
run: pnpm install --frozen-lockfile
41+
working-directory: .
42+
43+
- name: Run security regression tests
44+
run: pnpm test -- security.regression.test.ts
45+
env:
46+
JWT_SECRET: ci-test-secret
47+
REDIS_URL: redis://localhost:6379
48+
49+
dependency-audit:
50+
name: Crypto dependency CVE audit
51+
runs-on: ubuntu-latest
52+
53+
steps:
54+
- name: Checkout
55+
uses: actions/checkout@v4
56+
57+
- name: Setup Node.js
58+
uses: actions/setup-node@v4
59+
with:
60+
node-version: '20'
61+
62+
- name: Install pnpm
63+
uses: pnpm/action-setup@v4
64+
65+
- name: Install dependencies
66+
run: pnpm install --frozen-lockfile
67+
68+
- name: Audit crypto-relevant dependencies
69+
run: node scripts/audit-crypto-deps.mjs

apps/backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
"ioredis": "^5.11.0",
3939
"jsonwebtoken": "^9.0.3",
4040
"morgan": "^1.10.1",
41+
"pino": "^9.6.0",
4142
"postgres": "^3.4.9",
43+
"prom-client": "^15.1.3",
4244
"redis": "^6.0.0",
4345
"socket.io": "^4.8.3",
4446
"web-push": "^3.6.7",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import request from 'supertest';
3+
4+
const mockExecute = vi.fn();
5+
6+
vi.mock('../db/index.js', () => ({
7+
db: {
8+
execute: mockExecute,
9+
query: {
10+
conversations: { findFirst: vi.fn() },
11+
conversationMembers: { findFirst: vi.fn(), findMany: vi.fn() },
12+
messages: { findFirst: vi.fn() },
13+
tokenTransfers: { findFirst: vi.fn(), findMany: vi.fn() },
14+
users: { findFirst: vi.fn() },
15+
wallets: { findFirst: vi.fn() },
16+
},
17+
},
18+
}));
19+
20+
vi.mock('../services/pushNotification.js', () => ({
21+
dispatchOfflinePush: vi.fn().mockResolvedValue(undefined),
22+
reenableExpiredBackoffs: vi.fn().mockResolvedValue(undefined),
23+
FILE_CONTENT_TYPES: new Set<string>(),
24+
}));
25+
26+
vi.mock('../services/deliveryPipeline.js', () => ({
27+
deliverMessage: vi.fn().mockResolvedValue(undefined),
28+
}));
29+
30+
vi.mock('../services/deviceDelivery.js', () => ({
31+
publishToDevice: vi.fn().mockResolvedValue(undefined),
32+
}));
33+
34+
const { app } = await import('../app.js');
35+
const {
36+
messagesPersistedTotal,
37+
fanoutSize,
38+
pushResultTotal,
39+
presenceChurnTotal,
40+
backpressureEventsTotal,
41+
} = await import('../lib/metrics.js');
42+
43+
beforeEach(() => {
44+
vi.clearAllMocks();
45+
});
46+
47+
describe('GET /metrics', () => {
48+
it('exposes Prometheus-format metrics', async () => {
49+
const res = await request(app).get('/metrics');
50+
51+
expect(res.status).toBe(200);
52+
expect(res.headers['content-type']).toContain('text/plain');
53+
expect(res.text).toContain('clicked_messages_persisted_total');
54+
expect(res.text).toContain('clicked_fanout_size');
55+
expect(res.text).toContain('clicked_delivery_latency_seconds');
56+
expect(res.text).toContain('clicked_prekey_consumed_total');
57+
expect(res.text).toContain('clicked_push_result_total');
58+
expect(res.text).toContain('clicked_presence_churn_total');
59+
expect(res.text).toContain('clicked_backpressure_events_total');
60+
expect(res.text).toContain('clicked_connected_sockets');
61+
});
62+
63+
it('never contains ciphertext, envelope payloads, or free-text content', async () => {
64+
messagesPersistedTotal.inc({ contentType: 'text' });
65+
fanoutSize.observe(3);
66+
pushResultTotal.inc({ result: 'sent' });
67+
presenceChurnTotal.inc({ transition: 'online' });
68+
backpressureEventsTotal.inc({ action: 'shed' });
69+
70+
const res = await request(app).get('/metrics');
71+
72+
expect(res.text).not.toMatch(/ciphertext/i);
73+
expect(res.text).not.toMatch(/plaintext/i);
74+
expect(res.text).not.toMatch(/envelope.{0,20}[A-Za-z0-9+/]{20,}/i);
75+
});
76+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readFileSync, readdirSync } from 'fs';
3+
import { join, extname, dirname } from 'path';
4+
import { fileURLToPath } from 'url';
5+
import { validateMessagePayload } from '../lib/validateMessagePayload.js';
6+
import { SendMessageSchema } from '../schemas/message.schemas.js';
7+
8+
/**
9+
* Security regression checks (#388).
10+
*
11+
* These tests fail CI the moment any code path starts accepting a
12+
* plaintext-only message, or any schema/route grows a field that could
13+
* carry a raw private key or Signal session-state blob. They are a guard
14+
* against regressions, not a substitute for the crypto design itself.
15+
*/
16+
17+
const FORBIDDEN_FIELD_NAMES = [
18+
'plaintext',
19+
'plainText',
20+
'privateKey',
21+
'private_key',
22+
'sessionState',
23+
'session_state',
24+
'signalSession',
25+
'identityPrivateKey',
26+
'preKeyPrivate',
27+
];
28+
29+
const SRC_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
30+
31+
function listSourceFiles(dir: string): string[] {
32+
const entries = readdirSync(dir, { withFileTypes: true });
33+
const files: string[] = [];
34+
for (const entry of entries) {
35+
if (entry.name === '__tests__' || entry.name === 'node_modules') continue;
36+
const full = join(dir, entry.name);
37+
if (entry.isDirectory()) {
38+
files.push(...listSourceFiles(full));
39+
} else if (extname(entry.name) === '.ts') {
40+
files.push(full);
41+
}
42+
}
43+
return files;
44+
}
45+
46+
describe('security regression: ciphertext-only guard', () => {
47+
it('rejects a text message with plaintext content and no envelopes', () => {
48+
const result = validateMessagePayload({
49+
contentType: 'text',
50+
// @ts-expect-error - deliberately probing for a plaintext field the type doesn't allow
51+
plaintext: 'hello in the clear',
52+
});
53+
expect(result.ok).toBe(false);
54+
});
55+
56+
it('rejects a text message that supplies ciphertext but no per-device envelopes', () => {
57+
const result = validateMessagePayload({
58+
contentType: 'text',
59+
ciphertext: 'some-ciphertext',
60+
});
61+
expect(result.ok).toBe(false);
62+
});
63+
64+
it('accepts a text message only when envelopes carry the encrypted key', () => {
65+
const result = validateMessagePayload({
66+
contentType: 'text',
67+
envelopes: [{ recipientDeviceId: 'device-1', ciphertext: 'enc-key' }],
68+
});
69+
expect(result.ok).toBe(true);
70+
});
71+
72+
it('REST SendMessageSchema has no plaintext field', () => {
73+
const shape = SendMessageSchema.shape as Record<string, unknown>;
74+
expect(Object.keys(shape)).not.toContain('plaintext');
75+
expect(Object.keys(shape)).not.toContain('plainText');
76+
});
77+
});
78+
79+
describe('security regression: no private-key/session-state field is ever accepted', () => {
80+
const sourceFiles = listSourceFiles(SRC_ROOT);
81+
82+
it('scanned at least one route/schema file', () => {
83+
expect(sourceFiles.length).toBeGreaterThan(0);
84+
});
85+
86+
it.each(FORBIDDEN_FIELD_NAMES)('no source file declares a "%s" field', (fieldName) => {
87+
const offenders: string[] = [];
88+
// Matches z.object key declarations and TS interface/type field declarations,
89+
// e.g. `privateKey:` — not matched inside comments-only prose or unrelated words.
90+
const pattern = new RegExp(`(^|[^A-Za-z0-9_])${fieldName}\\s*[:?]\\s*[^,]`, 'm');
91+
92+
for (const file of sourceFiles) {
93+
const content = readFileSync(file, 'utf-8');
94+
if (pattern.test(content)) {
95+
offenders.push(file);
96+
}
97+
}
98+
99+
expect(offenders).toEqual([]);
100+
});
101+
});

0 commit comments

Comments
 (0)