From 2c694f388bc17bd53b906e41a82a57b58bda5faa Mon Sep 17 00:00:00 2001 From: Al Francis Date: Fri, 29 May 2026 20:53:58 -0700 Subject: [PATCH] fix(VAN-20): secure researcher drafts and preferences --- app/api/drafts/route.ts | 71 +++ app/api/hacktivity/route.ts | 11 +- app/api/hall-of-fame/route.ts | 24 +- app/api/hall-of-fame/stats/route.ts | 18 +- app/api/reports/similar/route.ts | 39 ++ app/api/researcher/[id]/route.ts | 24 +- app/api/researcher/preferences/route.ts | 55 ++ app/researcher/[id]/page.tsx | 60 ++ app/submit/page.tsx | 748 ++++++++++++++++-------- lib/db/schema.ts | 13 + lib/services/hall-of-fame.ts | 18 +- lib/validation.ts | 16 + migrations/0014_researcher_features.sql | 14 + tests/validation.test.ts | 31 + 14 files changed, 889 insertions(+), 253 deletions(-) create mode 100644 app/api/drafts/route.ts create mode 100644 app/api/reports/similar/route.ts create mode 100644 app/api/researcher/preferences/route.ts create mode 100644 migrations/0014_researcher_features.sql diff --git a/app/api/drafts/route.ts b/app/api/drafts/route.ts new file mode 100644 index 0000000..24bedb6 --- /dev/null +++ b/app/api/drafts/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { getDb, getCfEnv } from '@/lib/db'; +import { reportDrafts } from '@/lib/db/schema'; +import { decryptText, encryptText } from '@/lib/crypto'; +import { ReportDraftDataSchema } from '@/lib/validation'; +import { auth } from '@clerk/nextjs/server'; + +export async function GET() { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const db = getDb(getCfEnv().DB); + const draft = await db.select().from(reportDrafts).where(eq(reportDrafts.clerkUserId, userId)).get(); + if (!draft) return NextResponse.json({ draft: null }); + + try { + const plaintext = await decryptText(draft.data, draft.dataIv); + return NextResponse.json({ draft: { data: JSON.parse(plaintext), updatedAt: draft.updatedAt } }); + } catch (err) { + console.error('[GET /api/drafts] Failed to decrypt draft', err); + return NextResponse.json({ draft: null }); + } +} + +export async function POST(request: NextRequest) { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + let body: unknown; + try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const parsed = ReportDraftDataSchema.safeParse((body as { data?: unknown }).data); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ); + } + + const db = getDb(getCfEnv().DB); + const now = Date.now(); + const encrypted = await encryptText(JSON.stringify(parsed.data)); + const existing = await db.select({ id: reportDrafts.id }).from(reportDrafts).where(eq(reportDrafts.clerkUserId, userId)).get(); + + if (existing) { + await db.update(reportDrafts) + .set({ data: encrypted.ciphertext, dataIv: encrypted.iv, updatedAt: now }) + .where(eq(reportDrafts.clerkUserId, userId)); + } else { + await db.insert(reportDrafts).values({ + id: crypto.randomUUID(), + clerkUserId: userId, + data: encrypted.ciphertext, + dataIv: encrypted.iv, + createdAt: now, + updatedAt: now, + }); + } + + return NextResponse.json({ success: true }); +} + +export async function DELETE() { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const db = getDb(getCfEnv().DB); + await db.delete(reportDrafts).where(eq(reportDrafts.clerkUserId, userId)); + return NextResponse.json({ success: true }); +} diff --git a/app/api/hacktivity/route.ts b/app/api/hacktivity/route.ts index 08a5777..9d8468c 100644 --- a/app/api/hacktivity/route.ts +++ b/app/api/hacktivity/route.ts @@ -5,14 +5,14 @@ * When titleDisclosed is 0, the title is redacted to prevent disclosure * of undisclosed vulnerability reports. */ -import { NextRequest, NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; import { getDb, getCfEnv } from '@/lib/db'; -import { hacktivity, hallOfFame } from '@/lib/db/schema'; -import { desc, eq } from 'drizzle-orm'; +import { hacktivity, hallOfFame, researcherStats } from '@/lib/db/schema'; +import { and, desc, eq } from 'drizzle-orm'; import { clerkClient } from '@clerk/nextjs/server'; import { getDisplayName } from '@/lib/redact'; -export async function GET(_request: NextRequest) { +export async function GET() { try { const db = getDb(getCfEnv().DB); @@ -33,7 +33,8 @@ export async function GET(_request: NextRequest) { }) .from(hacktivity) .innerJoin(hallOfFame, eq(hacktivity.reportId, hallOfFame.reportId)) - .where(eq(hallOfFame.isPublic, 1)) + .innerJoin(researcherStats, eq(hacktivity.researcherId, researcherStats.researcherId)) + .where(and(eq(hallOfFame.isPublic, 1), eq(researcherStats.hofOptOut, 0))) .orderBy(desc(hacktivity.timestamp)) .limit(100) .all(); diff --git a/app/api/hall-of-fame/route.ts b/app/api/hall-of-fame/route.ts index 53fdf0a..702dbf1 100644 --- a/app/api/hall-of-fame/route.ts +++ b/app/api/hall-of-fame/route.ts @@ -1,21 +1,33 @@ /** * GET /api/hall-of-fame - Public leaderboard */ -import { NextRequest, NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; import { getDb, getCfEnv } from '@/lib/db'; -import { hallOfFame } from '@/lib/db/schema'; -import { desc, eq } from 'drizzle-orm'; +import { hallOfFame, researcherStats } from '@/lib/db/schema'; +import { and, desc, eq } from 'drizzle-orm'; import { clerkClient } from '@clerk/nextjs/server'; import { getDisplayName } from '@/lib/redact'; -export async function GET(_request: NextRequest) { +export async function GET() { try { const db = getDb(getCfEnv().DB); const publicAwards = await db - .select() + .select({ + id: hallOfFame.id, + reportId: hallOfFame.reportId, + researcherId: hallOfFame.researcherId, + title: hallOfFame.title, + publicTitle: hallOfFame.publicTitle, + severity: hallOfFame.severity, + pointsAwarded: hallOfFame.pointsAwarded, + acceptedAt: hallOfFame.acceptedAt, + isPublic: hallOfFame.isPublic, + createdAt: hallOfFame.createdAt, + }) .from(hallOfFame) - .where(eq(hallOfFame.isPublic, 1)) + .innerJoin(researcherStats, eq(hallOfFame.researcherId, researcherStats.researcherId)) + .where(and(eq(hallOfFame.isPublic, 1), eq(researcherStats.hofOptOut, 0))) .orderBy(desc(hallOfFame.acceptedAt)) .all(); diff --git a/app/api/hall-of-fame/stats/route.ts b/app/api/hall-of-fame/stats/route.ts index fdbe6b9..9fcf13c 100644 --- a/app/api/hall-of-fame/stats/route.ts +++ b/app/api/hall-of-fame/stats/route.ts @@ -1,21 +1,27 @@ /** * GET /api/hall-of-fame/stats - Overall statistics */ -import { NextRequest, NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; import { getDb, getCfEnv } from '@/lib/db'; -import { hallOfFame } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { hallOfFame, researcherStats } from '@/lib/db/schema'; +import { and, eq } from 'drizzle-orm'; import { clerkClient } from '@clerk/nextjs/server'; import { getDisplayName } from '@/lib/redact'; -export async function GET(_request: NextRequest) { +export async function GET() { try { const db = getDb(getCfEnv().DB); const publicAwards = await db - .select() + .select({ + id: hallOfFame.id, + researcherId: hallOfFame.researcherId, + pointsAwarded: hallOfFame.pointsAwarded, + acceptedAt: hallOfFame.acceptedAt, + }) .from(hallOfFame) - .where(eq(hallOfFame.isPublic, 1)) + .innerJoin(researcherStats, eq(hallOfFame.researcherId, researcherStats.researcherId)) + .where(and(eq(hallOfFame.isPublic, 1), eq(researcherStats.hofOptOut, 0))) .all(); // Calculate totals diff --git a/app/api/reports/similar/route.ts b/app/api/reports/similar/route.ts new file mode 100644 index 0000000..6c83e9d --- /dev/null +++ b/app/api/reports/similar/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { getDb, getCfEnv } from '@/lib/db'; +import { reports } from '@/lib/db/schema'; +import { auth } from '@clerk/nextjs/server'; + +function wordOverlapScore(a: string, b: string): number { + const tokenize = (s: string) => new Set(s.toLowerCase().split(/\W+/).filter(w => w.length > 3)); + const wordsA = tokenize(a); + const wordsB = tokenize(b); + if (wordsA.size === 0 || wordsB.size === 0) return 0; + let matches = 0; + wordsB.forEach(w => { if (wordsA.has(w)) matches++; }); + return matches / Math.max(wordsA.size, wordsB.size); +} + +export async function GET(request: NextRequest) { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const title = request.nextUrl.searchParams.get('title')?.trim() ?? ''; + if (title.length < 10) return NextResponse.json({ similar: [] }); + + const db = getDb(getCfEnv().DB); + const ownReports = await db + .select({ id: reports.id, refId: reports.refId, title: reports.title, status: reports.status, severity: reports.severity }) + .from(reports) + .where(eq(reports.clerkUserId, userId)); + + const similar = ownReports + .filter(r => r.status !== 'rejected' && r.status !== 'duplicate') + .map(r => ({ ...r, score: wordOverlapScore(title, r.title) })) + .filter(r => r.score >= 0.4) + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map(({ id, refId, title, status, severity }) => ({ id, refId, title, status, severity })); + + return NextResponse.json({ similar }); +} diff --git a/app/api/researcher/[id]/route.ts b/app/api/researcher/[id]/route.ts index f1f8ffe..72ce044 100644 --- a/app/api/researcher/[id]/route.ts +++ b/app/api/researcher/[id]/route.ts @@ -1,11 +1,11 @@ /** - * GET /api/researcher/[id] - Public researcher profile (no auth required) + * GET /api/researcher/[id] - Public researcher profile */ import { NextRequest, NextResponse } from 'next/server'; import { getDb, getCfEnv } from '@/lib/db'; -import { hallOfFame } from '@/lib/db/schema'; +import { hallOfFame, researcherStats } from '@/lib/db/schema'; import { and, eq, desc } from 'drizzle-orm'; -import { clerkClient } from '@clerk/nextjs/server'; +import { auth, clerkClient } from '@clerk/nextjs/server'; import { getDisplayName } from '@/lib/redact'; export async function GET( @@ -14,12 +14,28 @@ export async function GET( ) { try { const { id } = await params; + const { userId } = await auth(); + const isOwner = userId === id; const db = getDb(getCfEnv().DB); + const statsRow = await db + .select({ hofOptOut: researcherStats.hofOptOut }) + .from(researcherStats) + .where(eq(researcherStats.researcherId, id)) + .get(); + + if ((statsRow?.hofOptOut ?? 0) === 1 && !isOwner) { + return NextResponse.json({ error: 'Researcher not found' }, { status: 404 }); + } + + const entryVisibility = isOwner + ? eq(hallOfFame.researcherId, id) + : and(eq(hallOfFame.researcherId, id), eq(hallOfFame.isPublic, 1)); + const publicEntries = await db .select() .from(hallOfFame) - .where(and(eq(hallOfFame.researcherId, id), eq(hallOfFame.isPublic, 1))) + .where(entryVisibility) .orderBy(desc(hallOfFame.acceptedAt)) .all(); diff --git a/app/api/researcher/preferences/route.ts b/app/api/researcher/preferences/route.ts new file mode 100644 index 0000000..9582a42 --- /dev/null +++ b/app/api/researcher/preferences/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { getDb, getCfEnv } from '@/lib/db'; +import { researcherStats } from '@/lib/db/schema'; +import { auth } from '@clerk/nextjs/server'; + +export async function GET() { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const db = getDb(getCfEnv().DB); + const row = await db + .select({ hofOptOut: researcherStats.hofOptOut }) + .from(researcherStats) + .where(eq(researcherStats.researcherId, userId)) + .get(); + + return NextResponse.json({ hofOptOut: (row?.hofOptOut ?? 0) === 1 }); +} + +export async function PATCH(request: NextRequest) { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + let body: unknown; + try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const { hofOptOut } = body as { hofOptOut?: unknown }; + if (typeof hofOptOut !== 'boolean') { + return NextResponse.json({ error: 'hofOptOut must be a boolean' }, { status: 400 }); + } + + const db = getDb(getCfEnv().DB); + const now = Date.now(); + const existing = await db + .select({ researcherId: researcherStats.researcherId }) + .from(researcherStats) + .where(eq(researcherStats.researcherId, userId)) + .get(); + + if (existing) { + await db + .update(researcherStats) + .set({ hofOptOut: hofOptOut ? 1 : 0, updatedAt: now }) + .where(eq(researcherStats.researcherId, userId)); + } else { + await db.insert(researcherStats).values({ + researcherId: userId, + hofOptOut: hofOptOut ? 1 : 0, + updatedAt: now, + }); + } + + return NextResponse.json({ success: true }); +} diff --git a/app/researcher/[id]/page.tsx b/app/researcher/[id]/page.tsx index f639031..de4df89 100644 --- a/app/researcher/[id]/page.tsx +++ b/app/researcher/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; import { useParams } from "next/navigation"; +import { useUser } from "@clerk/nextjs"; import Link from "next/link"; import SiteHeader from "../../components/SiteHeader"; import SiteFooter from "../../components/SiteFooter"; @@ -50,10 +51,40 @@ function sevStyle(sev: string) { export default function ResearcherProfile() { const params = useParams(); const id = params.id as string; + const { user } = useUser(); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); + const [hofOptOut, setHofOptOut] = useState(false); + const [savingPref, setSavingPref] = useState(false); + + const isOwnProfile = !!user && user.id === id; + + useEffect(() => { + if (!isOwnProfile) return; + fetch("/api/researcher/preferences") + .then((r) => r.json()) + .then((d) => setHofOptOut(d.hofOptOut ?? false)) + .catch(() => {}); + }, [isOwnProfile]); + + async function toggleHofOptOut() { + setSavingPref(true); + const next = !hofOptOut; + try { + await fetch("/api/researcher/preferences", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hofOptOut: next }), + }); + setHofOptOut(next); + } catch { + // revert on failure + } finally { + setSavingPref(false); + } + } useEffect(() => { if (!id) return; @@ -178,6 +209,35 @@ export default function ResearcherProfile() { + {/* HoF visibility — only shown to the profile owner */} + {isOwnProfile && ( +
+

Hall of Fame Visibility

+

+ Control whether your profile and accepted reports appear publicly on the Hall of Fame. +

+ +
+ )} +
← Back to Hall of Fame diff --git a/app/submit/page.tsx b/app/submit/page.tsx index ecbf23d..3e77189 100644 --- a/app/submit/page.tsx +++ b/app/submit/page.tsx @@ -1,24 +1,33 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; +import React, { useState, useEffect, useRef } from "react"; import Link from "next/link"; import SiteHeader from "../components/SiteHeader"; import SiteFooter from "../components/SiteFooter"; -// ─── Scope interface ────────────────────────────────────────────────────────── +// ─── Types ──────────────────────────────────────────────────────────────────── + interface Scope { id: string; domain: string; description: string | null; targetType: string; status: string; - allowedVulnTypes: string | null; // JSON array string | null - severityRestriction: string | null; // JSON array string | null + allowedVulnTypes: string | null; + severityRestriction: string | null; notes: string | null; exclusionPaths: string | null; } -// ─── Vulnerability categories ───────────────────────────────────────────────── +interface SimilarReport { + id: string; + refId: string; + title: string; + status: string; + severity: string; +} + +// ─── Vuln types & severities ────────────────────────────────────────────────── + const VULN_TYPES = [ "Broken Access Control", "Cryptographic Failure", @@ -39,7 +48,6 @@ const VULN_TYPES = [ "Other", ]; -// ─── Severity definitions ───────────────────────────────────────────────────── const SEVERITIES = [ { value: "Critical", @@ -78,7 +86,75 @@ const SEVERITIES = [ }, ] as const; +// ─── Vuln templates ─────────────────────────────────────────────────────────── + +interface VulnTemplate { + description: string; + stepsToReproduce: string; + impact: string; +} + +const TEMPLATES: Partial> = { + "IDOR (Insecure Direct Object Reference)": { + description: + "The [endpoint] endpoint does not verify that the authenticated user owns the requested resource. By manipulating the object ID in the request, an attacker can access or modify data belonging to other users.", + stepsToReproduce: + "1. Log in as User A\n2. Note your resource ID (e.g. /api/resource/123)\n3. Log in as User B in a separate session\n4. Send a request to /api/resource/123 as User B\n5. Observe that User B can access or modify User A's resource", + impact: + "An attacker can read, modify, or delete any user's data without authorisation, violating confidentiality and integrity of user-owned resources.", + }, + "Injection (SQL / XSS / Command / SSTI)": { + description: + "The [parameter/field] at [endpoint] does not sanitise user input before including it in [SQL queries / HTML output / shell commands / template rendering]. An attacker can inject malicious payloads to [exfiltrate data / execute scripts / run OS commands].", + stepsToReproduce: + "1. Navigate to [the vulnerable page/endpoint]\n2. Locate the [input field / parameter]\n3. Insert the following payload: [your payload here]\n4. Submit the request\n5. Observe [the injected behaviour / error / output]", + impact: + "Successful exploitation allows an attacker to [describe impact: read database contents / steal session cookies / execute arbitrary OS commands], potentially leading to full system compromise.", + }, + "Broken Access Control": { + description: + "The application does not enforce proper authorisation checks on [endpoint / feature]. Users with [low-privilege role] can perform actions or access resources that should be restricted to [admin / higher-privilege] users.", + stepsToReproduce: + "1. Log in as a low-privilege user\n2. Directly navigate to or call [the restricted endpoint]\n3. Observe that the action succeeds without authorisation\n4. Confirm by observing [state change / data returned]", + impact: + "Unauthorised users can perform privileged actions such as [describe action], leading to data exposure, privilege escalation, or destructive operations.", + }, + "SSRF (Server-Side Request Forgery)": { + description: + "The [endpoint] accepts a user-supplied URL and makes a server-side HTTP request without validating the destination. An attacker can supply internal or cloud-metadata URLs to probe internal services.", + stepsToReproduce: + "1. Identify the parameter that accepts a URL (e.g. `url`, `webhook`, `callback`)\n2. Submit a request with the value set to `http://169.254.169.254/latest/meta-data/` (AWS metadata) or `http://localhost:[port]/`\n3. Observe the response contains data from the internal request", + impact: + "An attacker can enumerate internal services, read cloud provider metadata (including IAM credentials), or pivot to internal network resources not otherwise reachable.", + }, + "Authentication / Session Failure": { + description: + "The application's authentication or session management is flawed. [Describe the specific weakness: e.g. session tokens are not invalidated on logout / weak password reset tokens / JWT signature not verified].", + stepsToReproduce: + "1. [Step to trigger the authentication flow]\n2. [Step showing the flaw, e.g. intercept the request / reuse a token]\n3. Observe that access is granted without valid credentials", + impact: + "An attacker can gain unauthorised access to user accounts or administrative functions, leading to account takeover or privilege escalation.", + }, + "Information Disclosure / Data Leak": { + description: + "The [endpoint / error response / HTTP header / file] exposes sensitive information that should not be accessible. This includes [describe: stack traces / internal paths / API keys / PII / source code].", + stepsToReproduce: + "1. [Navigate to or call the endpoint]\n2. [Trigger the condition: e.g. send a malformed request / access the path directly]\n3. Observe the response contains [the sensitive data]", + impact: + "Exposed information can be leveraged by an attacker to plan further attacks, compromise accounts, or violate user privacy and applicable data protection regulations.", + }, + "Path Traversal / File Inclusion": { + description: + "The [endpoint] accepts a file path parameter and reads or includes files without sanitising directory traversal sequences (`../`). An attacker can read arbitrary files from the server filesystem.", + stepsToReproduce: + "1. Identify the parameter that accepts a filename or path\n2. Replace the value with `../../../../etc/passwd` (or equivalent)\n3. URL-encode traversal sequences if necessary\n4. Observe the response contains the contents of the target file", + impact: + "An attacker can read sensitive server files including credentials, private keys, application configuration, and operating system files, enabling further compromise.", + }, +}; + // ─── Form state ─────────────────────────────────────────────────────────────── + interface FormData { target: string; vulnType: string; @@ -107,11 +183,13 @@ const EMPTY: FormData = { agreed: false, }; -function generateLocalRef() { - return `VDP-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; -} +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const inputCls = (err?: string) => + `w-full border rounded-lg px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white ${ + err ? "border-red-400" : "border-gray-300" + }`; -// ─── Field wrapper ──────────────────────────────────────────────────────────── function Field({ label, required, @@ -138,115 +216,267 @@ function Field({ ); } -const inputCls = (err?: string) => - `w-full border rounded-lg px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white ${ - err ? "border-red-400" : "border-gray-300" - }`; +const SEV_BADGE: Record = { + Critical: "bg-red-100 text-red-800", + High: "bg-orange-100 text-orange-800", + Medium: "bg-yellow-100 text-yellow-800", + Low: "bg-blue-100 text-blue-800", + Info: "bg-gray-100 text-gray-700", +}; + +// ─── Step indicator ─────────────────────────────────────────────────────────── + +function StepIndicator({ step }: { step: 1 | 2 | 3 }) { + const steps = [ + { n: 1, label: "Target & Classification" }, + { n: 2, label: "Vulnerability Details" }, + { n: 3, label: "Review & Submit" }, + ]; + return ( +
+
+ {steps.map((s, i) => ( + +
+
s.n + ? "bg-green-500 text-white" + : step === s.n + ? "bg-blue-600 text-white" + : "bg-gray-100 text-gray-400" + }`} + > + {step > s.n ? "✓" : s.n} +
+ +
+ {i < steps.length - 1 && ( +
s.n ? "bg-green-400" : "bg-gray-200"}`} /> + )} + + ))} +
+
+ ); +} // ─── Page ───────────────────────────────────────────────────────────────────── + export default function SubmitReport() { + const [step, setStep] = useState<1 | 2 | 3>(1); const [form, setForm] = useState(EMPTY); const [errors, setErrors] = useState({}); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [referenceId, setReferenceId] = useState(null); + const [scopes, setScopes] = useState([]); const [loadingScopes, setLoadingScopes] = useState(true); - const selectedScope = scopes.find(s => s.domain === form.target) ?? null; + const [draftStatus, setDraftStatus] = useState<"idle" | "saving" | "saved">("idle"); + const draftTimerRef = useRef | null>(null); + const initialLoadDone = useRef(false); + + const [duplicates, setDuplicates] = useState([]); + const dupTimerRef = useRef | null>(null); + + const selectedScope = scopes.find((s) => s.domain === form.target) ?? null; const allowedVulnTypes: string[] = selectedScope?.allowedVulnTypes ? JSON.parse(selectedScope.allowedVulnTypes) : []; const allowedSeverities: string[] = selectedScope?.severityRestriction ? JSON.parse(selectedScope.severityRestriction) : []; + const availableTemplate = TEMPLATES[form.vulnType] ?? null; - // Fetch active scopes from API + // ── Load scopes ───────────────────────────────────────────────────────────── useEffect(() => { - async function fetchScopes() { - try { - const res = await fetch('/api/scopes'); - if (res.ok) { - const data = await res.json(); - setScopes(data.scopes || []); + fetch("/api/scopes") + .then((r) => r.json()) + .then((d) => setScopes(d.scopes ?? [])) + .catch(() => {}) + .finally(() => setLoadingScopes(false)); + }, []); + + // ── Load draft on mount ────────────────────────────────────────────────────── + useEffect(() => { + fetch("/api/drafts") + .then((r) => r.json()) + .then((d) => { + if (d.draft?.data) { + setForm((prev) => ({ ...prev, ...d.draft.data, agreed: false })); } - } catch (err) { - console.error('[fetchScopes] Error:', err); - } finally { - setLoadingScopes(false); + }) + .catch(() => {}) + .finally(() => { initialLoadDone.current = true; }); + }, []); + + // ── Auto-save draft (debounced 1.5s) ──────────────────────────────────────── + useEffect(() => { + if (!initialLoadDone.current) return; + const hasContent = form.target || form.title || form.description || form.vulnType; + if (!hasContent) return; + + if (draftTimerRef.current) clearTimeout(draftTimerRef.current); + draftTimerRef.current = setTimeout(async () => { + setDraftStatus("saving"); + try { + const draftData = { + target: form.target, + vulnType: form.vulnType, + severity: form.severity, + title: form.title, + description: form.description, + stepsToReproduce: form.stepsToReproduce, + impact: form.impact, + cvss: form.cvss, + evidence: form.evidence, + }; + await fetch("/api/drafts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: draftData }), + }); + setDraftStatus("saved"); + setTimeout(() => setDraftStatus("idle"), 2000); + } catch { + setDraftStatus("idle"); } + }, 1500); + + return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current); }; + }, [form]); + + // ── Duplicate check (debounced 800ms, step 2 only) ─────────────────────────── + useEffect(() => { + if (step !== 2 || form.title.length < 10) { + setDuplicates([]); + return; } - fetchScopes(); - }, []); + if (dupTimerRef.current) clearTimeout(dupTimerRef.current); + dupTimerRef.current = setTimeout(async () => { + try { + const res = await fetch(`/api/reports/similar?title=${encodeURIComponent(form.title)}`); + const d = await res.json(); + setDuplicates(d.similar ?? []); + } catch { + setDuplicates([]); + } + }, 800); + return () => { if (dupTimerRef.current) clearTimeout(dupTimerRef.current); }; + }, [form.title, step]); + function set(field: keyof FormData, value: string | boolean) { setForm((prev) => ({ ...prev, [field]: value })); if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined })); } - function validate(): boolean { + function applyTemplate() { + if (!availableTemplate) return; + setForm((prev) => ({ + ...prev, + description: availableTemplate.description, + stepsToReproduce: availableTemplate.stepsToReproduce, + impact: availableTemplate.impact, + })); + } + + // ── Step validation ────────────────────────────────────────────────────────── + function validateStep1(): boolean { const e: FormErrors = {}; - if (!form.target) e.target = "Please select a target"; - if (!form.vulnType) e.vulnType = "Please select a vulnerability type"; - if (!form.severity) e.severity = "Please select a severity level"; - if (!form.title.trim()) e.title = "Title is required"; - else if (form.title.trim().length < 10) e.title = "Title must be at least 10 characters"; - if (!form.description.trim()) e.description = "Description is required"; - else if (form.description.trim().length < 30) e.description = "Description must be at least 30 characters"; - if (!form.stepsToReproduce.trim()) e.stepsToReproduce = "Steps to reproduce are required"; + if (!form.target) e.target = "Please select a target"; + if (!form.vulnType) e.vulnType = "Please select a vulnerability type"; + if (!form.severity) e.severity = "Please select a severity level"; + setErrors(e); + return Object.keys(e).length === 0; + } + + function validateStep2(): boolean { + const e: FormErrors = {}; + if (!form.title.trim()) e.title = "Title is required"; + else if (form.title.trim().length < 10) e.title = "Title must be at least 10 characters"; + if (!form.description.trim()) e.description = "Description is required"; + else if (form.description.trim().length < 30) e.description = "Description must be at least 30 characters"; + if (!form.stepsToReproduce.trim()) e.stepsToReproduce = "Steps to reproduce are required"; else if (form.stepsToReproduce.trim().length < 20) e.stepsToReproduce = "Steps must be at least 20 characters"; - if (!form.impact.trim()) e.impact = "Impact description is required"; - else if (form.impact.trim().length < 20) e.impact = "Impact must be at least 20 characters"; - if (!form.agreed) e.agreed = "You must accept the disclosure policy"; + if (!form.impact.trim()) e.impact = "Impact description is required"; + else if (form.impact.trim().length < 20) e.impact = "Impact must be at least 20 characters"; setErrors(e); + if (Object.keys(e).length > 0) { + document.querySelector("[data-field-error]")?.scrollIntoView({ behavior: "smooth", block: "center" }); + } return Object.keys(e).length === 0; } + function goNext() { + if (step === 1 && validateStep1()) setStep(2); + if (step === 2 && validateStep2()) setStep(3); + } + + function goBack() { + setErrors({}); + setStep((s) => (s - 1) as 1 | 2 | 3); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - setSubmitError(null); - if (!validate()) { - document.querySelector('[data-field-error]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (!form.agreed) { + setErrors({ agreed: "You must accept the disclosure policy" }); return; } - setSubmitting(true); + setSubmitError(null); try { - const { agreed: _agreed, ...reportPayload } = form; - const res = await fetch('/api/reports', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(reportPayload), + const payload = { + target: form.target, + vulnType: form.vulnType, + severity: form.severity, + title: form.title, + description: form.description, + stepsToReproduce: form.stepsToReproduce, + impact: form.impact, + cvss: form.cvss, + evidence: form.evidence, + }; + const res = await fetch("/api/reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), }); - const data = await res.json(); - if (!res.ok) { - // Map server-side validation errors back onto fields if (res.status === 422 && data.details) { const serverErrors: FormErrors = {}; for (const [field, msgs] of Object.entries(data.details as Record)) { serverErrors[field as keyof FormData] = (msgs as string[])[0]; } setErrors(serverErrors); - document.querySelector('[data-field-error]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Go back to the step that has the error + const step1Fields = ["target", "vulnType", "severity"]; + const step2Fields = ["title", "description", "stepsToReproduce", "impact"]; + if (step1Fields.some((f) => serverErrors[f as keyof FormData])) setStep(1); + else if (step2Fields.some((f) => serverErrors[f as keyof FormData])) setStep(2); } else { - // In dev, show the detail message so issues are visible immediately - const msg = (data.detail ?? data.error) as string | undefined; - setSubmitError(msg ?? 'Submission failed. Please try again.'); + setSubmitError((data.detail ?? data.error) ?? "Submission failed. Please try again."); } return; } - - setReferenceId(data.referenceId ?? data.ref_id ?? null); - if (!data.referenceId && !data.ref_id) { - setSubmitError('Report submitted but no reference ID was returned. Contact security@vanguardvdp.ph.'); - } + // Clear draft after successful submission + fetch("/api/drafts", { method: "DELETE" }).catch(() => {}); + setReferenceId(data.referenceId ?? null); } catch { - setSubmitError('Network error — please check your connection and try again.'); + setSubmitError("Network error — please check your connection and try again."); } finally { setSubmitting(false); } } - // ── Success screen ────────────────────────────────────────────────────────── + // ── Success screen ─────────────────────────────────────────────────────────── if (referenceId) { return (
@@ -255,31 +485,21 @@ export default function SubmitReport() {

Report Received!

-

- Thank you for helping make Vanguard VDP more secure. -

- +

Thank you for helping make Vanguard VDP more secure.

-

- Your Reference Number -

+

Your Reference Number

{referenceId}

-

We will acknowledge your report within 48 hours and provide a full response within{" "} 7 business days.

-
- + View My Reports ))}
- {errors.severity && ( -

{errors.severity}

- )} + {errors.severity &&

{errors.severity}

}
-
- {/* ── Section: Vulnerability Details ───────────────────────────── */} -
-

🔍 Vulnerability Details

-
+ + + )} + + {/* ── Step 2: Vulnerability Details ────────────────────────────────── */} + {step === 2 && ( + <> + {/* Template picker */} + {availableTemplate && ( +
+
+
+

📄 Template available for {form.vulnType}

+

Pre-fill the fields below with a starter template — you can edit it freely.

+
+ +
+
+ )} + +
+

🔍 Vulnerability Details

set("title", e.target.value)} className={inputCls(errors.title)} maxLength={200} - data-error={errors.title ? true : undefined} /> + {/* Duplicate warning */} + {duplicates.length > 0 && ( +
+

⚠️ Similar reports found in your submissions — check for duplicates before continuing:

+
    + {duplicates.map((d) => ( +
  • + {d.severity} + {d.refId} + {d.title} + {d.status} +
  • + ))} +
+
+ )} +