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
269 changes: 219 additions & 50 deletions app/admin/scope/page.tsx

Large diffs are not rendered by default.

106 changes: 47 additions & 59 deletions app/api/admin/scopes/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
/**
* PATCH /api/admin/scopes/[id] — Update scope
* DELETE /api/admin/scopes/[id] — Delete scope
* DELETE /api/admin/scopes/[id] — Soft-delete scope (sets deleted_at)
*/
import { NextRequest, NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { scopes } from '@/lib/db/schema';
import { requireRole } from '@/lib/auth';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { eq, isNull, and } from 'drizzle-orm';
import { VULN_TYPES, SEVERITIES } from '@/lib/validation';

// PATCH - Update scope
const UpdateScopeSchema = z.object({
domain: z.string().min(1).max(200).optional(),
description: z.string().max(500).optional().nullable(),
targetType: z.enum(['web_app', 'api', 'mobile', 'infrastructure']).optional(),
status: z.enum(['active', 'deprecated', 'out_of_scope']).optional(),
domain: z.string().min(1).max(200).optional(),
description: z.string().max(500).optional().nullable(),
targetType: z.enum(['web_app', 'api', 'mobile', 'infrastructure']).optional(),
status: z.enum(['active', 'deprecated', 'out_of_scope']).optional(),
allowedVulnTypes: z.array(z.enum(VULN_TYPES)).optional().nullable(),
severityRestriction: z.array(z.enum(SEVERITIES)).optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
exclusionPaths: z.string().max(2000).optional().nullable(),
restore: z.boolean().optional(),
});

export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
try {
console.log('[PATCH /api/admin/scopes/[id]] Starting request');
const authResult = await requireRole('ADMIN');
console.log('[PATCH /api/admin/scopes/[id]] Auth successful:', authResult.userId);
await requireRole('ADMIN');
const { id } = await context.params;
console.log('[PATCH /api/admin/scopes/[id]] Scope ID:', id);

let body: unknown;
try { body = await request.json(); } catch {
Expand All @@ -35,69 +38,55 @@ export async function PATCH(

const parsed = UpdateScopeSchema.safeParse(body);
if (!parsed.success) {
console.error('[PATCH /api/admin/scopes/[id]] Validation failed:', parsed.error.flatten());
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten().fieldErrors },
{ status: 422 },
);
}

const data = parsed.data;
console.log('[PATCH /api/admin/scopes/[id]] Parsed data:', JSON.stringify(data));

const db = getDb(getCfEnv().DB);

// Check if scope exists
const existing = await db.select().from(scopes).where(eq(scopes.id, id)).get();
const existing = await db.select().from(scopes)
.where(data.restore ? eq(scopes.id, id) : and(eq(scopes.id, id), isNull(scopes.deletedAt)))
.get();
if (!existing) {
console.error('[PATCH /api/admin/scopes/[id]] Scope not found:', id);
return NextResponse.json({ error: 'Scope not found' }, { status: 404 });
}
console.log('[PATCH /api/admin/scopes/[id]] Existing scope:', JSON.stringify(existing));

// Build update object with proper field mapping
const updateData: Partial<typeof scopes.$inferInsert> = {
updatedAt: Date.now(),
};

if (data.domain !== undefined) updateData.domain = data.domain;

const updateData: Partial<typeof scopes.$inferInsert> = { updatedAt: Date.now() };
if (data.restore) updateData.deletedAt = null;

if (data.domain !== undefined) updateData.domain = data.domain;
if (data.description !== undefined) updateData.description = data.description || null;
if (data.targetType !== undefined) updateData.targetType = data.targetType;
if (data.status !== undefined) updateData.status = data.status;

console.log('[PATCH /api/admin/scopes/[id]] Update data:', JSON.stringify(updateData));

// Update scope
try {
await db.update(scopes)
.set(updateData)
.where(eq(scopes.id, id));
console.log('[PATCH /api/admin/scopes/[id]] Update successful');
} catch (dbError) {
console.error('[PATCH /api/admin/scopes/[id]] Database error:', dbError);
console.error('[PATCH /api/admin/scopes/[id]] Database error details:', JSON.stringify(dbError));
throw dbError;
if (data.targetType !== undefined) updateData.targetType = data.targetType;
if (data.status !== undefined) updateData.status = data.status;
if (data.notes !== undefined) updateData.notes = data.notes || null;
if (data.exclusionPaths !== undefined) updateData.exclusionPaths = data.exclusionPaths || null;

if (data.allowedVulnTypes !== undefined) {
updateData.allowedVulnTypes = data.allowedVulnTypes?.length
? JSON.stringify(data.allowedVulnTypes)
: null;
}
if (data.severityRestriction !== undefined) {
updateData.severityRestriction = data.severityRestriction?.length
? JSON.stringify(data.severityRestriction)
: null;
}

await db.update(scopes).set(updateData).where(eq(scopes.id, id));
const updated = await db.select().from(scopes).where(eq(scopes.id, id)).get();

return NextResponse.json({
success: true,
scope: updated,
});
return NextResponse.json({ success: true, scope: updated });
} catch (err) {
if (err instanceof Response) return err;
console.error('[PATCH /api/admin/scopes/[id]] Error:', err);
console.error('[PATCH /api/admin/scopes/[id]] Error message:', err instanceof Error ? err.message : String(err));
console.error('[PATCH /api/admin/scopes/[id]] Error stack:', err instanceof Error ? err.stack : 'No stack');
return NextResponse.json({
error: 'Internal server error',
details: err instanceof Error ? err.message : String(err)
}, { status: 500 });
console.error('[PATCH /api/admin/scopes/[id]]', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

// DELETE - Delete scope
// DELETE - Soft-delete scope
export async function DELETE(
_request: NextRequest,
context: { params: Promise<{ id: string }> },
Expand All @@ -108,19 +97,18 @@ export async function DELETE(

const db = getDb(getCfEnv().DB);

// Check if scope exists
const existing = await db.select().from(scopes).where(eq(scopes.id, id)).get();
const existing = await db.select().from(scopes)
.where(and(eq(scopes.id, id), isNull(scopes.deletedAt)))
.get();
if (!existing) {
return NextResponse.json({ error: 'Scope not found' }, { status: 404 });
}

// Delete scope
await db.delete(scopes).where(eq(scopes.id, id));
await db.update(scopes)
.set({ deletedAt: Date.now(), updatedAt: Date.now() })
.where(eq(scopes.id, id));

return NextResponse.json({
success: true,
message: 'Scope deleted successfully',
});
return NextResponse.json({ success: true, message: 'Scope archived successfully' });
} catch (err) {
if (err instanceof Response) return err;
console.error('[DELETE /api/admin/scopes/[id]]', err);
Expand Down
54 changes: 31 additions & 23 deletions app/api/admin/scopes/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
/**
* GET /api/admin/scopes — List all scopes
* GET /api/admin/scopes — List all non-deleted scopes
* POST /api/admin/scopes — Create new scope
*/
import { NextRequest, NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { scopes } from '@/lib/db/schema';
import { requireRole } from '@/lib/auth';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { eq, isNull } from 'drizzle-orm';
import { VULN_TYPES, SEVERITIES } from '@/lib/validation';

// GET - List all scopes
export async function GET(_request: NextRequest) {
// GET - List all non-deleted scopes
export async function GET(request: NextRequest) {
try {
await requireRole('ADMIN');

const includeArchived = request.nextUrl.searchParams.get('include_archived') === 'true';
const db = getDb(getCfEnv().DB);
const allScopes = await db.select().from(scopes).all();
const query = db.select().from(scopes);
const allScopes = includeArchived
? await query.all()
: await query.where(isNull(scopes.deletedAt)).all();

return NextResponse.json({
scopes: allScopes,
Expand All @@ -24,9 +29,7 @@ export async function GET(_request: NextRequest) {
} catch (err) {
if (err instanceof Response) return err;
console.error('[GET /api/admin/scopes] Error:', err);
console.error('[GET /api/admin/scopes] Error stack:', err instanceof Error ? err.stack : 'No stack');
console.error('[GET /api/admin/scopes] Error message:', err instanceof Error ? err.message : String(err));
return NextResponse.json({
return NextResponse.json({
error: 'Internal server error',
details: err instanceof Error ? err.message : String(err)
}, { status: 500 });
Expand All @@ -35,10 +38,14 @@ export async function GET(_request: NextRequest) {

// POST - Create new scope
const CreateScopeSchema = z.object({
domain: z.string().min(1).max(200),
description: z.string().max(500).optional(),
targetType: z.enum(['web_app', 'api', 'mobile', 'infrastructure']).optional(),
status: z.enum(['active', 'deprecated', 'out_of_scope']).optional(),
domain: z.string().min(1).max(200),
description: z.string().max(500).optional(),
targetType: z.enum(['web_app', 'api', 'mobile', 'infrastructure']).optional(),
status: z.enum(['active', 'deprecated', 'out_of_scope']).optional(),
allowedVulnTypes: z.array(z.enum(VULN_TYPES)).optional(),
severityRestriction: z.array(z.enum(SEVERITIES)).optional(),
notes: z.string().max(2000).optional(),
exclusionPaths: z.string().max(2000).optional(),
});

export async function POST(request: NextRequest) {
Expand All @@ -65,21 +72,22 @@ export async function POST(request: NextRequest) {

await db.insert(scopes).values({
id,
domain: data.domain,
description: data.description || null,
targetType: data.targetType || 'web_app',
status: data.status || 'active',
createdBy: userId,
createdAt: now,
updatedAt: now,
domain: data.domain,
description: data.description || null,
targetType: data.targetType || 'web_app',
status: data.status || 'active',
allowedVulnTypes: data.allowedVulnTypes?.length ? JSON.stringify(data.allowedVulnTypes) : null,
severityRestriction: data.severityRestriction?.length ? JSON.stringify(data.severityRestriction) : null,
notes: data.notes || null,
exclusionPaths: data.exclusionPaths || null,
createdBy: userId,
createdAt: now,
updatedAt: now,
});

const newScope = await db.select().from(scopes).where(eq(scopes.id, id)).get();

return NextResponse.json({
success: true,
scope: newScope,
}, { status: 201 });
return NextResponse.json({ success: true, scope: newScope }, { status: 201 });
} catch (err) {
if (err instanceof Response) return err;
console.error('[POST /api/admin/scopes]', err);
Expand Down
40 changes: 37 additions & 3 deletions app/api/reports/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* - Optionally notifies via Discord webhook
*/
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { and, eq, isNull } from 'drizzle-orm';
import { getDb, getCfEnv } from '@/lib/db';
import { reports, scopes } from '@/lib/db/schema';
import { encryptText, hashValue } from '@/lib/crypto';
Expand All @@ -29,6 +29,16 @@ function generateRefId(severity: string): string {
return `VVDP-${severityCode}-${year}-${random}`;
}

function parseScopeArray(value: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
}

export async function POST(request: NextRequest) {
// ── Rate limiting — prevent spam submissions ─────────────────────────────
const clientIp = getClientIP(request);
Expand Down Expand Up @@ -69,9 +79,17 @@ export async function POST(request: NextRequest) {

// ── Validate target against active scope records ─────────────────────────
const activeScope = await db
.select({ id: scopes.id })
.select({
id: scopes.id,
allowedVulnTypes: scopes.allowedVulnTypes,
severityRestriction: scopes.severityRestriction,
})
.from(scopes)
.where(and(eq(scopes.domain, data.target), eq(scopes.status, 'active')))
.where(and(
eq(scopes.domain, data.target),
eq(scopes.status, 'active'),
isNull(scopes.deletedAt),
))
.get();
if (!activeScope) {
return NextResponse.json(
Expand All @@ -80,6 +98,22 @@ export async function POST(request: NextRequest) {
);
}

const allowedVulnTypes = parseScopeArray(activeScope.allowedVulnTypes);
if (allowedVulnTypes.length > 0 && !allowedVulnTypes.includes(data.vulnType)) {
return NextResponse.json(
{ error: 'Validation failed', details: { vulnType: ['This vulnerability type is not allowed for the selected target'] } },
{ status: 422 },
);
}

const severityRestriction = parseScopeArray(activeScope.severityRestriction);
if (severityRestriction.length > 0 && !severityRestriction.includes(data.severity)) {
return NextResponse.json(
{ error: 'Validation failed', details: { severity: ['This severity is not allowed for the selected target'] } },
{ status: 422 },
);
}

// ── Ensure unique refId ──────────────────────────────────────────────────
let refId = generateRefId(data.severity);
const existing = await db.select().from(reports).where(eq(reports.refId, refId)).get();
Expand Down
20 changes: 12 additions & 8 deletions app/api/scopes/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
/**
* GET /api/scopes — Public endpoint to list active scopes
* GET /api/scopes — Public endpoint to list active scopes for the submission form
*/
import { NextRequest, NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { scopes } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { eq, isNull, and } from 'drizzle-orm';

export async function GET(_request: NextRequest) {
try {
const db = getDb(getCfEnv().DB);
const activeScopes = await db
.select({
id: scopes.id,
domain: scopes.domain,
description: scopes.description,
targetType: scopes.targetType,
status: scopes.status,
id: scopes.id,
domain: scopes.domain,
description: scopes.description,
targetType: scopes.targetType,
status: scopes.status,
allowedVulnTypes: scopes.allowedVulnTypes,
severityRestriction: scopes.severityRestriction,
notes: scopes.notes,
exclusionPaths: scopes.exclusionPaths,
})
.from(scopes)
.where(eq(scopes.status, 'active'))
.where(and(eq(scopes.status, 'active'), isNull(scopes.deletedAt)))
.all();

return NextResponse.json({
Expand Down
Loading
Loading