Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,8 @@ jobs:
run: npx tsx scripts/test-throttles.ts
env:
THROTTLE_TEST_DATABASE_URL: mongodb://127.0.0.1:27017/studio_throttle_test?replicaSet=rs0
- name: Verify Security Insights imports with MongoDB
run: npx tsx scripts/test-insights.ts
env:
INSIGHTS_TEST_DATABASE_URL: mongodb://127.0.0.1:27017/studio_insights_test?replicaSet=rs0
- run: npm run build
24 changes: 24 additions & 0 deletions docs/security-insights.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Security Insights automation

Uses the official CISA KEV JSON mirror. No AI key, paid scheduler, or browser/PIN automation is needed. Costs remain subject to existing Vercel Functions and database quotas.

- Daily Vercel cron: `/api/cron/security-insights`, `0 5 * * *` (05:00 UTC / approximately 10:30–11:30 IST on Hobby).
- Uses the existing production `CRON_SECRET`; missing or incorrect authorization fails closed.
- First successful run backfills up to eight draft briefs from the preceding 30 days, distributed across weeks. Existing manual CVE coverage is skipped.
- Subsequent runs create at most three briefs per UTC day. Imports default to unpublished drafts.
- Admin → Blog: daily collection switch, optional auto-publish switch, Sync now, Import past month, last success and errors.
- Past-month imports always remain drafts, including when auto-publish is enabled.
- A shared ten-minute MongoDB lease prevents concurrent cron/admin imports. Automatic retries have a twenty-minute cooldown. The endpoint runs for at most five minutes.
- Stable slugs and a durable CVE ledger prevent duplicates, preserve edits, and keep deleted imported posts from returning. Legacy manually created posts mentioning a CVE are recognized.
- Source strings are converted to plain text. The collector has a fixed source URL, no redirect following, a 20-second fetch deadline, an 8 MB body limit and schema validation.
- Briefs attribute CISA, distinguish catalog addition dates from publication dates, explain exploitation/ransomware uncertainty, and keep federal deadlines in context. Verify affected versions using vendor advisories before publishing.

## Deployment

Normal GitHub/Vercel deployment generates the Prisma client through the existing postinstall command. New MongoDB collections use string primary keys; no new secondary indexes or destructive schema migration are required. Existing posts and settings are unchanged. Do not run `prisma db push --accept-data-loss`.

Daily collection is enabled by default after deployment. Auto-publishing is disabled. The first cron initializes the collections and backfill automatically; Sync now can initialize them earlier.

## Verification

Unit tests cover source validation, date windows, budget limits, draft defaults, opt-in publishing, legacy coverage, deletion tracking, lease skips, failures, admin authentication and cron authentication. CI runs `scripts/test-insights.ts` against a disposable MongoDB replica set to verify atomic concurrency, persisted caps and editorial preservation. It cannot connect to production.
23 changes: 23 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,26 @@ model GitHubSyncState {
lockedUntil DateTime?
lockToken String?
}

/// Scheduled Security Insights settings and shared import lease.
model InsightSyncState {
id String @id @map("_id")
enabled Boolean @default(true)
autoPublish Boolean @default(false)
lastSuccess DateTime?
lastAttempt DateTime?
lastError String?
lockedUntil DateTime?
lockToken String?
backfilledAt DateTime?
lastCreated Int @default(0)
}

/// Durable deduplication, including posts deleted by an editor.
model InsightImport {
id String @id @map("_id")
postSlug String
sourceDate DateTime
importedAt DateTime @default(now())
backfill Boolean @default(false)
}
30 changes: 30 additions & 0 deletions scripts/test-insights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Uses the disposable CI database only; never production DATABASE_URL.
import assert from 'node:assert/strict';
import { PrismaClient } from '@prisma/client';
import { syncInsights } from '../src/lib/security-insights';
const url = process.env.INSIGHTS_TEST_DATABASE_URL;
if (!url || !/^mongodb:\/\/127\.0\.0\.1:27017\/studio_insights_test\?replicaSet=rs0$/.test(url)) throw new Error('Disposable insights database URL required');
const db = new PrismaClient({ datasourceUrl: url });
const originalFetch = global.fetch;
async function main() {
await db.blogPost.deleteMany(); await db.insightImport.deleteMany(); await db.insightSyncState.deleteMany();
const date = new Date().toISOString().slice(0, 10);
const entries = Array.from({ length: 20 }, (_, i) => ({ cveID: `CVE-2026-${90000 + i}`, vendorProject: 'Test', product: 'Product', dateAdded: date, shortDescription: 'Test only.', requiredAction: 'Test vendor update.', dueDate: date, knownRansomwareCampaignUse: 'Unknown' }));
global.fetch = async () => new Response(JSON.stringify({ vulnerabilities: entries }));
const first = await Promise.all([syncInsights(db, { automatic: true }), syncInsights(db, { automatic: true })]);
assert.equal(first.filter(r => r.skipped).length, 1, 'shared Mongo lease must skip one overlapping request');
assert.equal(await db.blogPost.count(), 8);
assert.equal(await db.blogPost.count({ where: { published: true } }), 0);
const edited = await db.blogPost.findFirstOrThrow();
await db.blogPost.update({ where: { id: edited.id }, data: { title: 'Manual edit' } });
assert.equal((await syncInsights(db)).created, 3);
assert.equal((await syncInsights(db)).created, 0, 'daily cap persists across invocations');
assert.equal((await db.blogPost.findUniqueOrThrow({ where: { id: edited.id } })).title, 'Manual edit');
await db.blogPost.delete({ where: { id: edited.id } });
await syncInsights(db, { backfill: true });
assert.equal(await db.blogPost.findUnique({ where: { slug: edited.slug } }), null, 'deleted imports must stay deleted');
await db.insightSyncState.update({ where: { id: 'security-insights' }, data: { enabled: false } });
assert.equal((await syncInsights(db, { automatic: true })).skipped, true);
console.log('Security Insights Mongo integration passed: lease, backfill, daily cap, edits, deletion and pause.');
}
main().finally(async () => { global.fetch = originalFetch; await db.$disconnect(); }).catch(error => { console.error(error); process.exitCode = 1; });
25 changes: 25 additions & 0 deletions src/app/api/cron/security-insights/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** @jest-environment node */
import { NextRequest } from 'next/server';
import { GET } from '../route';
import { syncInsights } from '@/lib/security-insights';
jest.mock('@/lib/prisma', () => ({ prisma: {} }));
jest.mock('@/lib/security-insights', () => ({ syncInsights: jest.fn() }));
jest.mock('@/lib/api', () => ({ revalidatePublic: jest.fn(), jsonError: (error: string, status: number) => Response.json({ error }, { status }) }));
const originalSecret = process.env.CRON_SECRET;
afterAll(() => { if (originalSecret === undefined) delete process.env.CRON_SECRET; else process.env.CRON_SECRET = originalSecret; });
beforeEach(() => jest.clearAllMocks());
const request = (authorization?: string) => new NextRequest('http://localhost/api/cron/security-insights', { headers: authorization ? { authorization } : {} });
test('fails closed for missing or incorrect cron credentials', async () => {
delete process.env.CRON_SECRET;
expect((await GET(request('Bearer undefined'))).status).toBe(401);
process.env.CRON_SECRET = 'test-secret';
expect((await GET(request())).status).toBe(401);
expect((await GET(request('Bearer wrong'))).status).toBe(401);
expect(syncInsights).not.toHaveBeenCalled();
});
test('valid cron runs the automatic insights collector', async () => {
process.env.CRON_SECRET = 'test-secret';
jest.mocked(syncInsights).mockResolvedValue({ skipped: false, created: 3, backfill: false });
expect((await GET(request('Bearer test-secret'))).status).toBe(200);
expect(syncInsights).toHaveBeenCalledWith({}, { automatic: true });
});
18 changes: 18 additions & 0 deletions src/app/api/cron/security-insights/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { timingSafeEqual } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { syncInsights } from '@/lib/security-insights';
import { jsonError, revalidatePublic } from '@/lib/api';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
export async function GET(request: NextRequest) {
const secret = process.env.CRON_SECRET;
const actual = Buffer.from(request.headers.get('authorization') ?? '');
const expected = Buffer.from(`Bearer ${secret ?? ''}`);
if (!secret || actual.length !== expected.length || !timingSafeEqual(actual, expected)) return jsonError('Unauthorized', 401);
try {
const result = await syncInsights(prisma, { automatic: true });
if (result.created) revalidatePublic();
return NextResponse.json({ success: true, ...result });
} catch (error) { console.error('Scheduled Security Insights failed:', error); return jsonError('Security Insights sync failed.', 503); }
}
23 changes: 23 additions & 0 deletions src/app/api/insights/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** @jest-environment node */
import { NextRequest } from 'next/server';
import { GET, POST, PATCH } from '../route';
import { requireAdminSession } from '@/lib/admin-auth';
import { syncInsights } from '@/lib/security-insights';
jest.mock('@/lib/admin-auth', () => ({ requireAdminSession: jest.fn() }));
jest.mock('@/lib/prisma', () => ({ prisma: { insightSyncState: { update: jest.fn() } } }));
jest.mock('@/lib/security-insights', () => ({ insightState: jest.fn().mockResolvedValue({ enabled: true, autoPublish: false, lockToken: 'private', lockedUntil: null }), syncInsights: jest.fn().mockResolvedValue({ created: 2, skipped: false }) }));
jest.mock('@/lib/api', () => ({ readJson: (r: Request) => r.json(), revalidatePublic: jest.fn(), jsonError: (error: string, status: number) => Response.json({ error }, { status }) }));
const req = (method: string, body?: object) => new NextRequest('http://localhost/api/insights', { method, ...(body ? { body: JSON.stringify(body) } : {}) });
beforeEach(() => { jest.clearAllMocks(); jest.mocked(requireAdminSession).mockResolvedValue(null); });
test('every admin handler rejects unauthenticated requests', async () => {
jest.mocked(requireAdminSession).mockResolvedValue(Response.json({}, { status: 401 }) as never);
for (const [handler, method] of [[GET, 'GET'], [POST, 'POST'], [PATCH, 'PATCH']] as const) expect((await handler(req(method))).status).toBe(401);
expect(syncInsights).not.toHaveBeenCalled();
});
test('status omits lease token and import validates arguments', async () => {
expect(await (await GET(req('GET'))).json()).not.toHaveProperty('lockToken');
expect((await POST(req('POST', { backfill: true }))).status).toBe(200);
expect(syncInsights).toHaveBeenCalledWith(expect.anything(), { backfill: true });
expect((await POST(req('POST', { source: 'http://localhost/' }))).status).toBe(400);
expect((await PATCH(req('PATCH', { autoPublish: 'true' }))).status).toBe(400);
});
38 changes: 38 additions & 0 deletions src/app/api/insights/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAdminSession } from '@/lib/admin-auth';
import { jsonError, readJson, revalidatePublic } from '@/lib/api';
import { prisma } from '@/lib/prisma';
import { insightState, syncInsights } from '@/lib/security-insights';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
const settings = z.object({ enabled: z.boolean().optional(), autoPublish: z.boolean().optional() }).strict();
function publicState(state: Awaited<ReturnType<typeof insightState>>) {
const { enabled, autoPublish, lastSuccess, lastAttempt, lastError, lastCreated, backfilledAt, lockedUntil } = state;
return { enabled, autoPublish, lastSuccess, lastAttempt, lastError, lastCreated, backfilledAt, running: !!lockedUntil && lockedUntil > new Date() };
}
export async function GET(request: NextRequest) {
const denied = await requireAdminSession(request); if (denied) return denied;
try { return NextResponse.json(publicState(await insightState(prisma))); }
catch { return jsonError('Could not load Security Insights settings.', 503); }
}
export async function PATCH(request: NextRequest) {
const denied = await requireAdminSession(request); if (denied) return denied;
const parsed = settings.safeParse(await readJson(request));
if (!parsed.success) return jsonError('Invalid settings.', 400);
try {
await insightState(prisma);
const state = await prisma.insightSyncState.update({ where: { id: 'security-insights' }, data: parsed.data });
return NextResponse.json(publicState(state));
} catch { return jsonError('Could not save settings.', 503); }
}
export async function POST(request: NextRequest) {
const denied = await requireAdminSession(request); if (denied) return denied;
const parsed = z.object({ backfill: z.boolean().optional() }).strict().safeParse(await readJson(request));
if (!parsed.success) return jsonError('Invalid import request.', 400);
try {
const result = await syncInsights(prisma, parsed.data);
if (result.created) revalidatePublic();
return NextResponse.json(result);
} catch (error) { console.error('Security Insights import failed:', error); return jsonError('Import failed. Retry later.', 503); }
}
24 changes: 24 additions & 0 deletions src/components/admin/__tests__/insights-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { InsightsPanel } from '../insights-panel';
import { api } from '../api-client';
jest.mock('lucide-react', () => ({ Loader2: () => null }));
jest.mock('../api-client', () => ({ api: jest.fn(), errorMessage: (e: Error) => e.message }));
jest.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: jest.fn() }) }));
const state = { enabled: true, autoPublish: false, lastSuccess: null, lastError: null, lastCreated: 0, running: false };
beforeEach(() => jest.clearAllMocks());
test('defaults to draft review and refreshes the blog list after month import', async () => {
jest.mocked(api).mockImplementation(async (_path, options) => options?.method === 'POST' ? { created: 8, skipped: false } as never : state as never);
const onImported = jest.fn(); render(<InsightsPanel onImported={onImported} />);
expect(await screen.findByRole('switch', { name: 'Auto-publish new Security Insights' })).toHaveAttribute('aria-checked', 'false');
fireEvent.click(screen.getByRole('button', { name: 'Import past month' }));
await waitFor(() => expect(onImported).toHaveBeenCalledTimes(1));
expect(api).toHaveBeenCalledWith('/api/insights', { method: 'POST', body: { backfill: true } });
});
test('failed status loads show retry without enabling settings', async () => {
jest.mocked(api).mockRejectedValue(new Error('Offline'));
render(<InsightsPanel onImported={jest.fn()} />);
expect(await screen.findByRole('alert')).toHaveTextContent('Offline');
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reload automation settings' })).toBeEnabled();
});
5 changes: 4 additions & 1 deletion src/components/admin/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { api } from './api-client';
import { CollectionEditor, type CollectionEditorProps } from './collection-editor';
import { ProfilePanel } from './profile-panel';
import { SkillsPanel } from './skills-panel';
import { InsightsPanel } from './insights-panel';
import { GitHubPanel } from './github-panel';
import { InboxPanel } from './inbox-panel';
import { ImportPanel } from './import-panel';
Expand Down Expand Up @@ -208,6 +209,7 @@ interface SessionInfo {
}

export function AdminDashboard({ onLogout }: { onLogout: () => void }) {
const [blogVersion, setBlogVersion] = useState(0);
const [tab, setTab] = useState<TabId>('profile');
const [session, setSession] = useState<SessionInfo | null>(null);
const [unread, setUnread] = useState(0);
Expand Down Expand Up @@ -315,7 +317,8 @@ export function AdminDashboard({ onLogout }: { onLogout: () => void }) {
{tab === 'requests' && <RequestsPanel onNewCountChange={setNewRequests} />}
{tab === 'import' && <ImportPanel />}
{tab === 'security' && <SecurityPanel onLogout={onLogout} />}
{tab in COLLECTIONS && <CollectionEditor key={tab} {...COLLECTIONS[tab]} />}
{tab === 'blog' && <InsightsPanel onImported={() => setBlogVersion(v => v + 1)} />}
{tab in COLLECTIONS && <CollectionEditor key={`${tab}-${blogVersion}`} {...COLLECTIONS[tab]} />}
</div>
</div>
</div>
Expand Down
45 changes: 45 additions & 0 deletions src/components/admin/insights-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api, errorMessage } from './api-client';
import { Btn, Panel, Toggle, formatDate } from './ui';
import { useToast } from '@/hooks/use-toast';
type State = { enabled: boolean; autoPublish: boolean; lastSuccess: string | null; lastError: string | null; lastCreated: number; running: boolean };
export function InsightsPanel({ onImported }: { onImported: () => void }) {
const [state, setState] = useState<State | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const load = useCallback(async () => {
try { setState(await api<State>('/api/insights')); setError(null); }
catch (e) { setError(errorMessage(e)); }
}, []);
useEffect(() => { void load(); }, [load]);
async function settings(body: Partial<Pick<State, 'enabled' | 'autoPublish'>>) {
setBusy(true);
try { setState(await api<State>('/api/insights', { method: 'PATCH', body })); setError(null); }
catch (e) { setError(errorMessage(e)); }
finally { setBusy(false); }
}
async function sync(backfill: boolean) {
setBusy(true);
try {
const result = await api<{ created: number; skipped: boolean }>('/api/insights', { method: 'POST', body: { backfill } });
toast({ title: result.skipped ? 'Import is already running' : `${result.created} Security Insights imported`, description: backfill ? 'Past-month imports are saved as drafts.' : 'Review the blog list below.' });
setError(null); onImported(); await load();
} catch (e) { setError(errorMessage(e)); }
finally { setBusy(false); }
}
return <Panel title="Security Insights automation" description="Checks CISA’s Known Exploited Vulnerabilities catalog daily, around 10:30–11:30 AM IST. Imports up to 3 briefs; the first run adds up to 8 drafts from the past 30 days. Your edits are preserved." className="mb-5">
{!state ? <Btn variant="outline" onClick={() => void load()}>Reload automation settings</Btn> : <>
<div className="flex flex-wrap items-center gap-5">
<div className="flex items-center gap-2"><Toggle label="Daily Security Insights collection" checked={state.enabled} disabled={busy} onChange={v => void settings({ enabled: v })} /><span className="text-xs">Daily collection</span></div>
<div className="flex items-center gap-2"><Toggle label="Auto-publish new Security Insights" checked={state.autoPublish} disabled={busy} onChange={v => void settings({ autoPublish: v })} /><span className="text-xs">Auto-publish new briefs</span></div>
<Btn busy={busy} onClick={() => void sync(false)}>Sync now</Btn>
<Btn disabled={busy} variant="outline" onClick={() => void sync(true)}>Import past month</Btn>
</div>
<p className="mt-3 text-xs text-muted-foreground">{state.autoPublish ? 'New briefs publish automatically. Past-month imports always remain drafts.' : 'New briefs are saved as drafts for review.'} Last success: {formatDate(state.lastSuccess, { dateStyle: 'medium', timeStyle: 'short' })} · {state.lastCreated} added.{state.running && ' An import is running.'}</p>
{state.lastError && <p role="alert" className="mt-2 text-xs text-destructive">{state.lastError}</p>}
</>}
{error && <p role="alert" className="mt-2 text-xs text-destructive">{error}</p>}
</Panel>;
}
Loading
Loading