Skip to content

Commit 5a1efbb

Browse files
authored
feat(kiloclaw) Security Advisor (#2201)
* feat(kiloclaw) Security Advisor
1 parent 2ccef24 commit 5a1efbb

File tree

17 files changed

+17392
-0
lines changed

17 files changed

+17392
-0
lines changed
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { describe, it, expect, beforeEach } from '@jest/globals';
2+
import { NextResponse } from 'next/server';
3+
import { getUserFromAuth } from '@/lib/user.server';
4+
import { failureResult } from '@/lib/maybe-result';
5+
import type { User } from '@kilocode/db/schema';
6+
import {
7+
checkSecurityAdvisorRateLimit,
8+
recordSecurityAdvisorScan,
9+
} from '@/lib/security-advisor/rate-limiter';
10+
import { trackSecurityAdvisorScanCompleted } from '@/lib/security-advisor/posthog-tracking';
11+
import { RATE_LIMIT_PER_DAY } from '@/lib/security-advisor/schemas';
12+
13+
// Capture after() callbacks so we can flush them in tests
14+
let afterCallbacks: (() => Promise<void>)[] = [];
15+
16+
jest.mock('next/server', () => {
17+
return {
18+
...(jest.requireActual('next/server') as Record<string, unknown>),
19+
after: (fn: () => Promise<void>) => {
20+
afterCallbacks.push(fn);
21+
},
22+
};
23+
});
24+
25+
jest.mock('@/lib/user.server');
26+
jest.mock('@/lib/security-advisor/rate-limiter');
27+
jest.mock('@/lib/security-advisor/posthog-tracking');
28+
jest.mock('@sentry/nextjs', () => ({
29+
captureException: jest.fn(),
30+
}));
31+
32+
const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
33+
const mockedCheckRateLimit = jest.mocked(checkSecurityAdvisorRateLimit);
34+
const mockedRecordScan = jest.mocked(recordSecurityAdvisorScan);
35+
const mockedTrackScan = jest.mocked(trackSecurityAdvisorScanCompleted);
36+
37+
function setUserAuth(id = 'user-123') {
38+
mockedGetUserFromAuth.mockResolvedValue({
39+
user: { id } as User,
40+
authFailedResponse: null,
41+
organizationId: 'org-456',
42+
});
43+
}
44+
45+
function setRateLimitAllowed(remaining = RATE_LIMIT_PER_DAY) {
46+
mockedCheckRateLimit.mockResolvedValue({ allowed: true, remaining });
47+
}
48+
49+
function setRateLimitExceeded() {
50+
mockedCheckRateLimit.mockResolvedValue({ allowed: false, remaining: 0 });
51+
}
52+
53+
async function flushAfterCallbacks() {
54+
for (const fn of afterCallbacks) {
55+
await fn();
56+
}
57+
afterCallbacks = [];
58+
}
59+
60+
const VALID_BODY = {
61+
apiVersion: '2026-04-01',
62+
source: { platform: 'openclaw', method: 'plugin', pluginVersion: '1.0.0' },
63+
audit: {
64+
ts: 1775491369820,
65+
summary: { critical: 1, warn: 0, info: 1 },
66+
findings: [
67+
{
68+
checkId: 'fs.config.perms_world_readable',
69+
severity: 'critical',
70+
title: 'Config file is world-readable',
71+
detail: '/root/.openclaw/openclaw.json mode=644',
72+
remediation: 'chmod 600 /root/.openclaw/openclaw.json',
73+
},
74+
{
75+
checkId: 'summary.attack_surface',
76+
severity: 'info',
77+
title: 'Attack surface summary',
78+
detail: 'groups: open=0',
79+
remediation: null,
80+
},
81+
],
82+
deep: { gateway: { attempted: true, ok: true } },
83+
secretDiagnostics: [],
84+
},
85+
publicIp: '1.2.3.4',
86+
};
87+
88+
function makeRequest(body: unknown = VALID_BODY) {
89+
return new Request('http://localhost:3000/api/security-advisor/analyze', {
90+
method: 'POST',
91+
headers: { 'Content-Type': 'application/json' },
92+
body: JSON.stringify(body),
93+
});
94+
}
95+
96+
describe('POST /api/security-advisor/analyze', () => {
97+
beforeEach(() => {
98+
jest.resetAllMocks();
99+
afterCallbacks = [];
100+
mockedRecordScan.mockResolvedValue(undefined);
101+
});
102+
103+
it('returns 401 when not authenticated', async () => {
104+
const authFailedResponse = NextResponse.json(failureResult('Unauthorized'), { status: 401 });
105+
mockedGetUserFromAuth.mockResolvedValue({
106+
user: null,
107+
authFailedResponse,
108+
});
109+
110+
const { POST } = await import('./route');
111+
const response = await POST(makeRequest() as never);
112+
expect(response).toBe(authFailedResponse);
113+
});
114+
115+
it('returns 400 for invalid JSON', async () => {
116+
setUserAuth();
117+
const { POST } = await import('./route');
118+
119+
const badRequest = new Request('http://localhost:3000/api/security-advisor/analyze', {
120+
method: 'POST',
121+
headers: { 'Content-Type': 'application/json' },
122+
body: 'not-json',
123+
});
124+
125+
const response = await POST(badRequest as never);
126+
expect(response.status).toBe(400);
127+
128+
const data = await response.json();
129+
expect(data.error.code).toBe('invalid_payload');
130+
});
131+
132+
it('returns 400 for wrong apiVersion', async () => {
133+
setUserAuth();
134+
const { POST } = await import('./route');
135+
136+
const response = await POST(makeRequest({ ...VALID_BODY, apiVersion: '2025-01-01' }) as never);
137+
expect(response.status).toBe(400);
138+
139+
const data = await response.json();
140+
expect(data.error.code).toBe('invalid_api_version');
141+
});
142+
143+
it('returns 400 for invalid payload', async () => {
144+
setUserAuth();
145+
const { POST } = await import('./route');
146+
147+
const response = await POST(
148+
makeRequest({ apiVersion: '2026-04-01', source: { platform: 'bad' } }) as never
149+
);
150+
expect(response.status).toBe(400);
151+
152+
const data = await response.json();
153+
expect(data.error.code).toBe('invalid_payload');
154+
});
155+
156+
it('returns 200 with structured report for valid request', async () => {
157+
setUserAuth();
158+
setRateLimitAllowed();
159+
const { POST } = await import('./route');
160+
161+
const response = await POST(makeRequest() as never);
162+
expect(response.status).toBe(200);
163+
164+
const data = await response.json();
165+
expect(data.apiVersion).toBe('2026-04-01');
166+
expect(data.status).toBe('success');
167+
expect(data.report.markdown).toContain('# Security Audit Report');
168+
expect(data.report.summary.critical).toBe(1);
169+
expect(data.report.findings).toHaveLength(2);
170+
expect(data.report.recommendations.length).toBeGreaterThan(0);
171+
});
172+
173+
it('includes sales comparison for openclaw source', async () => {
174+
setUserAuth();
175+
setRateLimitAllowed();
176+
const { POST } = await import('./route');
177+
178+
const response = await POST(makeRequest() as never);
179+
const data = await response.json();
180+
181+
const configFinding = data.report.findings.find(
182+
(f: { checkId: string }) => f.checkId === 'fs.config.perms_world_readable'
183+
);
184+
expect(configFinding.kiloClawComparison).toContain('How KiloClaw handles this');
185+
});
186+
187+
it('includes divergence warning for kiloclaw source', async () => {
188+
setUserAuth();
189+
setRateLimitAllowed();
190+
const { POST } = await import('./route');
191+
192+
const kiloClawBody = {
193+
...VALID_BODY,
194+
source: { platform: 'kiloclaw', method: 'plugin' },
195+
};
196+
const response = await POST(makeRequest(kiloClawBody) as never);
197+
const data = await response.json();
198+
199+
const configFinding = data.report.findings.find(
200+
(f: { checkId: string }) => f.checkId === 'fs.config.perms_world_readable'
201+
);
202+
expect(configFinding.kiloClawComparison).toContain('diverged');
203+
});
204+
205+
it('returns 429 when rate limit exceeded', async () => {
206+
setUserAuth();
207+
setRateLimitExceeded();
208+
const { POST } = await import('./route');
209+
210+
const response = await POST(makeRequest() as never);
211+
expect(response.status).toBe(429);
212+
213+
const data = await response.json();
214+
expect(data.error.code).toBe('rate_limited');
215+
});
216+
217+
it('records scan synchronously before response', async () => {
218+
setUserAuth();
219+
setRateLimitAllowed();
220+
const { POST } = await import('./route');
221+
222+
const response = await POST(makeRequest() as never);
223+
expect(response.status).toBe(200);
224+
225+
// DB write happens synchronously (before response), not in after()
226+
expect(mockedRecordScan).toHaveBeenCalledWith(
227+
'user-123',
228+
'org-456',
229+
expect.objectContaining({
230+
apiVersion: '2026-04-01',
231+
source: expect.objectContaining({ platform: 'openclaw' }),
232+
})
233+
);
234+
});
235+
236+
it('fires PostHog event in after() callback', async () => {
237+
setUserAuth();
238+
setRateLimitAllowed();
239+
const { POST } = await import('./route');
240+
241+
await POST(makeRequest() as never);
242+
243+
// PostHog fires in after() — not yet called
244+
expect(mockedTrackScan).not.toHaveBeenCalled();
245+
246+
await flushAfterCallbacks();
247+
248+
expect(mockedTrackScan).toHaveBeenCalledWith(
249+
expect.objectContaining({
250+
distinctId: 'user-123',
251+
userId: 'user-123',
252+
organizationId: 'org-456',
253+
sourcePlatform: 'openclaw',
254+
findingsCritical: 1,
255+
})
256+
);
257+
});
258+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { NextRequest } from 'next/server';
2+
import { NextResponse } from 'next/server';
3+
import { after } from 'next/server';
4+
import * as z from 'zod';
5+
import { getUserFromAuth } from '@/lib/user.server';
6+
import { captureException } from '@sentry/nextjs';
7+
import {
8+
SecurityAdvisorRequestSchema,
9+
API_VERSION,
10+
RATE_LIMIT_PER_DAY,
11+
type SecurityAdvisorError,
12+
type SecurityAdvisorResponse,
13+
} from '@/lib/security-advisor/schemas';
14+
import { generateSecurityReport } from '@/lib/security-advisor/report-generator';
15+
import {
16+
checkSecurityAdvisorRateLimit,
17+
recordSecurityAdvisorScan,
18+
} from '@/lib/security-advisor/rate-limiter';
19+
import { trackSecurityAdvisorScanCompleted } from '@/lib/security-advisor/posthog-tracking';
20+
21+
function errorResponse(
22+
code: SecurityAdvisorError['error']['code'],
23+
message: string,
24+
status: number,
25+
retryAfter?: number
26+
): NextResponse<SecurityAdvisorError> {
27+
return NextResponse.json(
28+
{
29+
apiVersion: API_VERSION,
30+
status: 'error' as const,
31+
error: { code, message, ...(retryAfter !== undefined ? { retryAfter } : {}) },
32+
},
33+
{ status }
34+
);
35+
}
36+
37+
export async function POST(request: NextRequest) {
38+
// 1. Auth
39+
const { user, authFailedResponse, organizationId } = await getUserFromAuth({
40+
adminOnly: false,
41+
});
42+
if (authFailedResponse) return authFailedResponse;
43+
44+
// 2. Parse body
45+
let body: unknown;
46+
try {
47+
body = await request.json();
48+
} catch {
49+
return errorResponse('invalid_payload', 'Invalid JSON body', 400);
50+
}
51+
52+
// 3. Check apiVersion before full validation (better error message)
53+
if (typeof body === 'object' && body !== null && 'apiVersion' in body) {
54+
if ((body as Record<string, unknown>).apiVersion !== API_VERSION) {
55+
return errorResponse(
56+
'invalid_api_version',
57+
`Unsupported API version. Expected "${API_VERSION}".`,
58+
400
59+
);
60+
}
61+
}
62+
63+
// 4. Validate payload
64+
const parseResult = SecurityAdvisorRequestSchema.safeParse(body);
65+
if (!parseResult.success) {
66+
return errorResponse(
67+
'invalid_payload',
68+
`Invalid request body: ${z.treeifyError(parseResult.error)}`,
69+
400
70+
);
71+
}
72+
73+
const payload = parseResult.data;
74+
75+
// 5. Rate limit (DB-backed, survives restarts, shared across replicas)
76+
const rateLimit = await checkSecurityAdvisorRateLimit(user.id);
77+
if (!rateLimit.allowed) {
78+
return errorResponse(
79+
'rate_limited',
80+
`Rate limit exceeded. You can run ${RATE_LIMIT_PER_DAY} scans per day.`,
81+
429
82+
);
83+
}
84+
85+
// 6. Generate report
86+
const isKiloClaw = payload.source.platform === 'kiloclaw';
87+
const report = generateSecurityReport({
88+
audit: payload.audit,
89+
publicIp: payload.publicIp,
90+
isKiloClaw,
91+
});
92+
93+
// 7. Record scan in DB (synchronous — must complete before response
94+
// so the rate limit counter is accurate under concurrent requests)
95+
await recordSecurityAdvisorScan(user.id, organizationId ?? undefined, payload);
96+
97+
// 8. Fire PostHog event (non-blocking — analytics don't need to block the response)
98+
after(() => {
99+
try {
100+
trackSecurityAdvisorScanCompleted({
101+
distinctId: user.id,
102+
userId: user.id,
103+
organizationId: organizationId ?? undefined,
104+
sourcePlatform: payload.source.platform,
105+
sourceMethod: payload.source.method,
106+
pluginVersion: payload.source.pluginVersion,
107+
openclawVersion: payload.source.openclawVersion,
108+
findingsCritical: report.summary.critical,
109+
findingsWarn: report.summary.warn,
110+
findingsInfo: report.summary.info,
111+
publicIp: payload.publicIp,
112+
});
113+
} catch (err) {
114+
captureException(err, { tags: { source: 'security_advisor_posthog' } });
115+
}
116+
});
117+
118+
// 9. Return structured response
119+
const response: SecurityAdvisorResponse = {
120+
apiVersion: API_VERSION,
121+
status: 'success',
122+
report: {
123+
markdown: report.markdown,
124+
summary: report.summary,
125+
findings: report.findings,
126+
recommendations: report.recommendations,
127+
},
128+
};
129+
130+
return NextResponse.json(response);
131+
}

0 commit comments

Comments
 (0)