diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index be5fc8e..26abaee 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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 diff --git a/docs/security-insights.md b/docs/security-insights.md new file mode 100644 index 0000000..4679577 --- /dev/null +++ b/docs/security-insights.md @@ -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. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 89fc6f4..3c2afef 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) +} diff --git a/scripts/test-insights.ts b/scripts/test-insights.ts new file mode 100644 index 0000000..550f307 --- /dev/null +++ b/scripts/test-insights.ts @@ -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; }); diff --git a/src/app/api/cron/security-insights/__tests__/route.test.ts b/src/app/api/cron/security-insights/__tests__/route.test.ts new file mode 100644 index 0000000..399dc34 --- /dev/null +++ b/src/app/api/cron/security-insights/__tests__/route.test.ts @@ -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 }); +}); diff --git a/src/app/api/cron/security-insights/route.ts b/src/app/api/cron/security-insights/route.ts new file mode 100644 index 0000000..048b070 --- /dev/null +++ b/src/app/api/cron/security-insights/route.ts @@ -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); } +} diff --git a/src/app/api/insights/__tests__/route.test.ts b/src/app/api/insights/__tests__/route.test.ts new file mode 100644 index 0000000..29046d1 --- /dev/null +++ b/src/app/api/insights/__tests__/route.test.ts @@ -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); +}); diff --git a/src/app/api/insights/route.ts b/src/app/api/insights/route.ts new file mode 100644 index 0000000..930aecf --- /dev/null +++ b/src/app/api/insights/route.ts @@ -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>) { + 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); } +} diff --git a/src/components/admin/__tests__/insights-panel.test.tsx b/src/components/admin/__tests__/insights-panel.test.tsx new file mode 100644 index 0000000..ab8266a --- /dev/null +++ b/src/components/admin/__tests__/insights-panel.test.tsx @@ -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(); + 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(); + expect(await screen.findByRole('alert')).toHaveTextContent('Offline'); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reload automation settings' })).toBeEnabled(); +}); diff --git a/src/components/admin/dashboard.tsx b/src/components/admin/dashboard.tsx index f6422ec..32d5abb 100644 --- a/src/components/admin/dashboard.tsx +++ b/src/components/admin/dashboard.tsx @@ -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'; @@ -208,6 +209,7 @@ interface SessionInfo { } export function AdminDashboard({ onLogout }: { onLogout: () => void }) { + const [blogVersion, setBlogVersion] = useState(0); const [tab, setTab] = useState('profile'); const [session, setSession] = useState(null); const [unread, setUnread] = useState(0); @@ -315,7 +317,8 @@ export function AdminDashboard({ onLogout }: { onLogout: () => void }) { {tab === 'requests' && } {tab === 'import' && } {tab === 'security' && } - {tab in COLLECTIONS && } + {tab === 'blog' && setBlogVersion(v => v + 1)} />} + {tab in COLLECTIONS && } diff --git a/src/components/admin/insights-panel.tsx b/src/components/admin/insights-panel.tsx new file mode 100644 index 0000000..a005cbf --- /dev/null +++ b/src/components/admin/insights-panel.tsx @@ -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(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const { toast } = useToast(); + const load = useCallback(async () => { + try { setState(await api('/api/insights')); setError(null); } + catch (e) { setError(errorMessage(e)); } + }, []); + useEffect(() => { void load(); }, [load]); + async function settings(body: Partial>) { + setBusy(true); + try { setState(await api('/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 + {!state ? void load()}>Reload automation settings : <> +
+
void settings({ enabled: v })} />Daily collection
+
void settings({ autoPublish: v })} />Auto-publish new briefs
+ void sync(false)}>Sync now + void sync(true)}>Import past month +
+

{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.'}

+ {state.lastError &&

{state.lastError}

} + } + {error &&

{error}

} +
; +} diff --git a/src/lib/__tests__/security-insights.test.ts b/src/lib/__tests__/security-insights.test.ts new file mode 100644 index 0000000..c45b05f --- /dev/null +++ b/src/lib/__tests__/security-insights.test.ts @@ -0,0 +1,83 @@ +/** @jest-environment node */ +import type { PrismaClient } from '@prisma/client'; +import { fetchCatalog, makeInsight, selectEntries, syncInsights, type KevEntry } from '../security-insights'; +const originalFetch = global.fetch; +const now = new Date('2026-10-08T07:00:00Z'); +const entry = (i = 1, dateAdded = '2026-10-07'): KevEntry => ({ cveID: `CVE-2026-${10000 + i}`, vendorProject: 'Vendor', product: 'Product', dateAdded, shortDescription: 'A vulnerability exists.', requiredAction: 'Apply vendor mitigations.', dueDate: '2026-10-20', knownRansomwareCampaignUse: 'Unknown' }); +const feed = (entries: KevEntry[]) => { global.fetch = jest.fn().mockResolvedValue(new Response(JSON.stringify({ vulnerabilities: entries }))); }; +function database({ backfilled = true, publish = false } = {}) { + const posts = new Map(); + const imports = new Map(); + const state = { id: 'security-insights', enabled: true, autoPublish: publish, backfilledAt: backfilled ? now : null }; + const db = { + insightSyncState: { upsert: jest.fn().mockResolvedValue(state), updateMany: jest.fn().mockResolvedValue({ count: 1 }) }, + insightImport: { + count: jest.fn().mockResolvedValue(0), findMany: jest.fn(async () => [...imports.values()]), + upsert: jest.fn(async ({ create }) => { imports.set(create.id, create); return create; }), + }, + blogPost: { + findMany: jest.fn(async () => [...posts.values()]), + upsert: jest.fn(async ({ create }) => { if (!posts.has(create.slug)) posts.set(create.slug, create); return posts.get(create.slug); }), + }, + }; + return { db, posts, imports, client: db as unknown as PrismaClient }; +} +beforeEach(() => { jest.useFakeTimers().setSystemTime(now); }); +afterEach(() => { global.fetch = originalFetch; jest.useRealTimers(); }); +test('month filtering excludes old and future entries; backfill spans weeks', () => { + const entries = [entry(1), entry(2, '2026-09-30'), entry(3, '2026-09-22'), entry(4, '2026-09-12'), entry(5, '2026-08-01'), entry(6, '2026-10-09')]; + expect(selectEntries(entries, now, true).map(e => e.cveID)).toEqual(entries.slice(0, 4).map(e => e.cveID)); +}); +test('feed validates entries and rejects unsafe or excessive input', async () => { + feed([entry()]); expect(await fetchCatalog()).toHaveLength(1); + feed([{ ...entry(), cveID: 'bad/path' }]); await expect(fetchCatalog()).rejects.toThrow(); + global.fetch = jest.fn().mockResolvedValue(new Response('{}', { headers: { 'content-length': '9000000' } })); + await expect(fetchCatalog()).rejects.toThrow('Catalog too large'); +}); +test('template removes markup, retains dates and explains uncertainty', () => { + const post = makeInsight({ ...entry(), product: '