feat: scanner

This commit is contained in:
dextmorgn
2025-02-28 12:12:33 +01:00
parent 69373e387a
commit 083a690333
9 changed files with 325 additions and 232 deletions

View File

@@ -8,20 +8,25 @@ app = FastAPI()
class EmailScanItem(BaseModel):
email: str
investigation_id:str
@app.post("/scan/")
async def scan(item: EmailScanItem, db: Client = Depends(get_db)):
task_name = "email_scan"
task = celery_app.send_task(task_name, args=[item.email])
assert item.email is not None
assert item.investigation_id is not None
try:
response = db.table("scans").insert({
"id": task.id,
"status": "pending",
"scan_name": task_name
"scan_name": task_name,
"value":item.email,
"investigation_id": item.investigation_id
}).execute()
return {"id": task.id, "response": response}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Server error: {str(e)}")
@app.get("/health")
async def health():

View File

@@ -0,0 +1,28 @@
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
export async function GET(_: Request, { params }: { params: Promise<{ investigation_id: string, scan_id: string }> }) {
const { investigation_id, scan_id } = await params
try {
const supabase = await createClient()
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (!user || userError) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { data: scan, error } = await supabase
.from('scans')
.select(`*`)
.eq("investigation_id", investigation_id)
.eq('id', scan_id)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(scan)
} catch (error) {
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,27 @@
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
export async function GET(_: Request, { params }: { params: Promise<{ investigation_id: string }> }) {
const { investigation_id } = await params
try {
const supabase = await createClient()
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (!user || userError) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { data: scans, error } = await supabase
.from('scans')
.select(`*`)
.eq("investigation_id", investigation_id)
.order("created_at", { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(scans)
} catch (error) {
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}

View File

@@ -10,7 +10,7 @@ interface DashboardClientProps {
}
export default function DashboardClient({ investigationId }: DashboardClientProps) {
// Use the initial data from the server, but enable background updates
const [view, setView] = useQueryState("view", { defaultValue: "flow-graph" })
const [view, _] = useQueryState("view", { defaultValue: "flow-graph" })
const graphQuery = useQuery({
queryKey: ["investigation", investigationId, "data"],
queryFn: async () => {

View File

@@ -83,7 +83,7 @@ function EmailNode({ data }: any) {
<ContextMenuSubTrigger>Search</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => {
toast.promise(checkEmail(data.email), {
toast.promise(checkEmail(data.email, data.investigation_id), {
loading: 'Loading...',
success: () => {
return `Scan on ${data.email} has been launched.`;

View File

@@ -22,6 +22,9 @@ import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useQueryState } from "nuqs"
import { useQuery } from "@tanstack/react-query"
import { useParams } from "next/navigation"
import Loader from "../loader"
export type ScanResult = {
name: string
@@ -41,6 +44,7 @@ export type ErrorResult = {
export type Scan = {
id: string
value: string,
status: "pending" | "finished" | "error"
results: {
results: (ScanResult | ErrorResult)[]
@@ -48,14 +52,26 @@ export type Scan = {
created_at: string
}
export function ScanDrawer() {
const [currentScan, setCurrentScan] = useState<Scan | null>(null)
const { investigation_id } = useParams()
const [scanId, setScanId] = useQueryState("scan_id")
const [open, setOpen] = useState(Boolean(scanId))
const [searchQuery, setSearchQuery] = useState("")
const [viewMode, setViewMode] = useState<"grid" | "table">("table")
const { data: currentScan = null, isLoading } = useQuery({
queryKey: ["investigation", investigation_id, "scans", scanId],
queryFn: async (): Promise<Scan | null> => {
const res = await fetch(`/api/investigations/${investigation_id}/scans/${scanId}`)
if (!res.ok) {
return null
}
return res.json()
},
enabled: !!scanId,
refetchOnWindowFocus: true,
})
useEffect(() => {
if (Boolean(scanId)) setOpen(true)
}, [scanId])
@@ -64,22 +80,11 @@ export function ScanDrawer() {
if (!open) setScanId(null)
}, [open])
useEffect(() => {
const fetchScan = async () => {
if (scanId) {
const { data: scan } = await supabase.from("scans").select("*").eq("id", scanId).single()
setCurrentScan(scan)
}
}
fetchScan()
}, [supabase, setCurrentScan, scanId])
const isErrorResult = (result: ScanResult | ErrorResult): result is ErrorResult => {
return "error" in result
}
const filteredResults = currentScan?.results.results.filter((result: any) => {
const filteredResults = currentScan?.results ? currentScan?.results?.results.filter((result: any) => {
if (isErrorResult(result)) {
return result.error.toLowerCase().includes(searchQuery.toLowerCase())
}
@@ -87,9 +92,9 @@ export function ScanDrawer() {
result.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
result.domain.toLowerCase().includes(searchQuery.toLowerCase())
)
})
}) : null
const stats = currentScan?.results.results.reduce(
const stats = currentScan?.results ? currentScan?.results.results.reduce(
(acc: any, result: any) => {
if (isErrorResult(result)) {
acc.errors++
@@ -100,195 +105,216 @@ export function ScanDrawer() {
return acc
},
{ exists: 0, rateLimited: 0, errors: 0 },
)
) : null
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="h-[95vh] !max-h-none">
<DrawerHeader className="border-b pb-4 p-4">
<DrawerTitle className="flex items-center gap-2 mb-2">
Scan Results
<Badge variant={currentScan?.status === "error" ? "destructive" : "outline"}>{currentScan?.status}</Badge>
</DrawerTitle>
<p className="opacity-60 mb-2 mt-2">{currentScan?.id}</p>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-4 mb-4">
<Card className="bg-background shadow-none">
<CardContent className="p-4 flex items-center gap-2">
<Shield className="h-4 w-4 text-green-500" />
<div>
<p className="text-sm font-medium">Existing Accounts</p>
<p className="text-2xl font-bold">{stats?.exists || 0}</p>
</div>
</CardContent>
</Card>
<Card className="bg-background shadow-none">
<CardContent className="p-4 flex items-center gap-2">
<Clock className="h-4 w-4 text-yellow-500" />
<div>
<p className="text-sm font-medium">Rate Limited</p>
<p className="text-2xl font-bold">{stats?.rateLimited || 0}</p>
</div>
</CardContent>
</Card>
<Card className="bg-background shadow-none">
<CardContent className="p-4 flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-destructive" />
<div>
<p className="text-sm font-medium">Errors</p>
<p className="text-2xl font-bold">{stats?.errors || 0}</p>
</div>
</CardContent>
</Card>
</div>
{/* Search and View Toggle */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search services..."
className="pl-8 shadow-none"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button variant="outline" size="icon" onClick={() => setViewMode(viewMode === "grid" ? "table" : "grid")}>
{viewMode === "grid" ? <LayoutList className="h-4 w-4" /> : <Grid2X2 className="h-4 w-4" />}
</Button>
</div>
</DrawerHeader>
<div className="h-auto overflow-auto">
{viewMode === "table" ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredResults?.map((result: any, index: number) => (
<TableRow key={index}>
{isErrorResult(result) ? (
<>
<TableCell className="font-medium dark:text-red-400 text-destructive px-4"><div>Error</div></TableCell>
<TableCell colSpan={3} className="dark:text-red-400 text-destructive">
{result.error}
</TableCell>
</>
) : (
<>
<TableCell className="font-medium">
<div className="flex items-center gap-2 px-2">
{result.name}
<a
href={`https://${result.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{result.exists ? (
<Badge className="text-green-500 bg-green-400/10 border border-green-500/30">
<CheckCircle2 className="h-3 w-3 mr-1" />
Exists
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="h-3 w-3 mr-1" />
Not Found
</Badge>
)}
</div>
</TableCell>
<TableCell>{result.method}</TableCell>
<TableCell>
<div className="flex gap-2">
{result.rateLimit && (
<Badge variant="destructive" className="text-yellow-500 bg-yellow-400/10 border border-yellow-500/30">
Rate Limited
</Badge>
)}
{result.frequent_rate_limit && (
<Badge variant="destructive" className="text-orange-500 bg-orange-400/10 border border-orange-500/30">
Frequent Limits
</Badge>
)}
</div>
</TableCell>
</>
)}
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 p-4 overflow-auto">
{filteredResults?.map((result: any, index: number) => (
<Card key={index} className="overflow-hidden bg-background">
<CardContent className="p-4">
{isErrorResult(result) ? (
<div className="flex items-start gap-2 text-destructive">
<AlertCircle className="h-4 w-4 mt-1" />
<div>
<p className="font-medium">Error</p>
<p className="text-sm">{result.error}</p>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 font-medium">
{result.name}
<a
href={`https://${result.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
{result.exists ? (
<Badge className="bg-green-500">
<CheckCircle2 className="h-3 w-3 mr-1" />
Exists
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="h-3 w-3 mr-1" />
Not Found
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">Method: {result.method}</div>
<div className="flex flex-wrap gap-2">
{result.rateLimit && (
<Badge variant="destructive" className="bg-yellow-500">
Rate Limited
</Badge>
)}
{result.frequent_rate_limit && (
<Badge variant="destructive" className="bg-orange-500">
Frequent Limits
</Badge>
)}
</div>
</div>
)}
<DrawerContent className="h-[95vh] !max-h-none border">
{isLoading &&
<DrawerHeader className="border-b pb-4 p-4">
<DrawerTitle className="flex items-center gap-2 mb-2">
Scan Results
<Badge variant={currentScan?.status === "error" ? "destructive" : "outline"}>{currentScan?.status}</Badge>
</DrawerTitle>
<p className="opacity-60 mb-2 mt-2">{currentScan?.value}</p>
<div className="h-[400px] w-full flex items-center justify-center gap-1"><Loader /> Loading results...</div>
</DrawerHeader>}
{!isLoading && stats && filteredResults ?
<>
<DrawerHeader className="border-b pb-4 p-4">
<DrawerTitle className="flex items-center gap-2 mb-2">
Scan Results
<Badge variant={currentScan?.status === "error" ? "destructive" : "outline"}>{currentScan?.status}</Badge>
</DrawerTitle>
<p className="opacity-60 mb-2 mt-2">{currentScan?.value}</p>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-4 mb-4">
<Card className="bg-background shadow-none">
<CardContent className="p-4 flex items-center gap-2">
<Shield className="h-4 w-4 text-green-500" />
<div>
<p className="text-sm font-medium">Existing Accounts</p>
<p className="text-2xl font-bold">{stats?.exists || 0}</p>
</div>
</CardContent>
</Card>
))}
<Card className="bg-background shadow-none">
<CardContent className="p-4 flex items-center gap-2">
<Clock className="h-4 w-4 text-yellow-500" />
<div>
<p className="text-sm font-medium">Rate Limited</p>
<p className="text-2xl font-bold">{stats?.rateLimited || 0}</p>
</div>
</CardContent>
</Card>
<Card className="bg-background shadow-none">
<CardContent className="p-4 flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-destructive" />
<div>
<p className="text-sm font-medium">Errors</p>
<p className="text-2xl font-bold">{stats?.errors || 0}</p>
</div>
</CardContent>
</Card>
</div>
{/* Search and View Toggle */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search services..."
className="pl-8 shadow-none"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button variant="outline" size="icon" onClick={() => setViewMode(viewMode === "grid" ? "table" : "grid")}>
{viewMode === "grid" ? <LayoutList className="h-4 w-4" /> : <Grid2X2 className="h-4 w-4" />}
</Button>
</div>
</DrawerHeader>
<div className="h-auto overflow-auto">
{viewMode === "table" ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredResults?.map((result: any, index: number) => (
<TableRow key={index}>
{isErrorResult(result) ? (
<>
<TableCell className="font-medium dark:text-red-400 text-destructive px-4"><div>Error</div></TableCell>
<TableCell colSpan={3} className="dark:text-red-400 text-destructive">
{result.error}
</TableCell>
</>
) : (
<>
<TableCell className="font-medium">
<div className="flex items-center gap-2 px-2">
{result.name}
<a
href={`https://${result.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{result.exists ? (
<Badge className="text-green-500 bg-green-400/10 border border-green-500/30">
<CheckCircle2 className="h-3 w-3 mr-1" />
Exists
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="h-3 w-3 mr-1" />
Not Found
</Badge>
)}
</div>
</TableCell>
<TableCell>{result.method}</TableCell>
<TableCell>
<div className="flex gap-2">
{result.rateLimit && (
<Badge variant="destructive" className="text-yellow-500 bg-yellow-400/10 border border-yellow-500/30">
Rate Limited
</Badge>
)}
{result.frequent_rate_limit && (
<Badge variant="destructive" className="text-orange-500 bg-orange-400/10 border border-orange-500/30">
Frequent Limits
</Badge>
)}
</div>
</TableCell>
</>
)}
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 p-4 overflow-auto">
{filteredResults?.map((result: any, index: number) => (
<Card key={index} className="overflow-hidden bg-background">
<CardContent className="p-4">
{isErrorResult(result) ? (
<div className="flex items-start gap-2 text-destructive">
<AlertCircle className="h-4 w-4 mt-1" />
<div>
<p className="font-medium">Error</p>
<p className="text-sm">{result.error}</p>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 font-medium">
{result.name}
<a
href={`https://${result.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
{result.exists ? (
<Badge className="bg-green-500">
<CheckCircle2 className="h-3 w-3 mr-1" />
Exists
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="h-3 w-3 mr-1" />
Not Found
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">Method: {result.method}</div>
<div className="flex flex-wrap gap-2">
{result.rateLimit && (
<Badge variant="destructive" className="bg-yellow-500">
Rate Limited
</Badge>
)}
{result.frequent_rate_limit && (
<Badge variant="destructive" className="bg-orange-500">
Frequent Limits
</Badge>
)}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
</div>
</> : !isLoading && <div><DrawerHeader className="border-b pb-4 p-4">
<DrawerTitle className="flex items-center gap-2 mb-2">
Scan Results
<Badge variant={currentScan?.status === "error" ? "destructive" : "outline"}>{currentScan?.status}</Badge>
</DrawerTitle>
<p className="opacity-60 mb-2 mt-2">{currentScan?.value}</p>
<div>Results are not there yet. They should appear soon.</div>
</DrawerHeader>
</div>}
</DrawerContent>
</Drawer>
)

View File

@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { Zap } from "lucide-react"
import { Zap, ZapIcon } from "lucide-react"
import { createClient } from "@supabase/supabase-js"
import { useQueryState } from "nuqs"
@@ -27,7 +27,7 @@ const supabase = createClient(supabaseUrl, supabaseKey)
export type Scan = {
id: string
scan_name: string
value: string
status: "pending" | "finished" | "failed"
results: { results: any[] }
}
@@ -72,11 +72,12 @@ export function ScanButton() {
setScans((current) => {
const newScans = current.map((scan) => (scan.id === payload.new.id ? (payload.new as Scan) : scan))
updatePendingCount(newScans)
toast('Scan complete ! view results.', {
action: {
label: 'Results',
onClick: () => setScanId(payload.new.id),
}
toast(<div>
<p className="text-primary font-medium">{payload.new.value}</p>
<div className="flex items-center gap-1">
<p> Scan complete on this item.</p> <Button variant="outline" onClick={() => setScanId(payload.new.id)}>Click to see results</Button>
</div></div>, {
duration: 6000,
})
return newScans
})
@@ -110,20 +111,25 @@ export function ScanButton() {
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size={"icon"} className="h-full relative w-12 rounded-none">
<Zap className="mr-2 h-4 w-4" />
{pendingCount > 0 ? (
<Badge variant="default" className="ml-2 bg-primary/50 absolute top-1 right-1 text-primary-foreground rounded-full">
{pendingCount}
</Badge>
<>
<ZapIcon className="mr-2 !h-4 !w-4" />
<Badge variant="default" className="text-xs px-0 h-5 w-5 absolute top-1 right-1 border-none rounded-full">
{pendingCount}
</Badge>
</>
) : (
<Badge variant="outline" className="ml-2 absolute top-1 right-1 border-none rounded-full">
0
</Badge>
<>
<ZapIcon className="mr-2 !h-4 !w-4" />
<Badge variant="outline" className="text-xs px-0 h-5 w-5 absolute top-1 right-1 border-none rounded-full">
0
</Badge>
</>
)}
</Button>
</SheetTrigger>
<SheetContent>
<div className="mx-auto w-full max-w-4xl h-screen overflow-auto">
<SheetContent className="w-full !max-w-[500px]">
<div className="mx-auto w-full !max-w-[500px] h-screen overflow-auto">
<SheetHeader>
<SheetTitle>Scans</SheetTitle>
<SheetDescription>View all your scans. Click on a scan to select it.</SheetDescription>

View File

@@ -15,7 +15,7 @@ export function ScanTable({ scans, onScanClick, selectedScanId }: ScanTableProps
<Table>
<TableHeader>
<TableRow>
<TableHead>Scan Name</TableHead>
<TableHead>Item</TableHead>
<TableHead>Status</TableHead>
<TableHead>Results</TableHead>
</TableRow>
@@ -34,7 +34,7 @@ export function ScanTable({ scans, onScanClick, selectedScanId }: ScanTableProps
className={`cursor-pointer hover:bg-muted/50 ${selectedScanId === scan.id ? "bg-muted" : ""}`}
onClick={() => onScanClick(scan.id)}
>
<TableCell className="font-medium">{scan.scan_name}</TableCell>
<TableCell className="font-medium"><span className=" max-w-[200px] block truncate text-ellipsis">{scan.value}</span></TableCell>
<TableCell>
{scan.status === "pending" ? (
<Badge variant="default" className="bg-yellow-500 hover:bg-yellow-600">

View File

@@ -1,19 +1,20 @@
'use server'
import { createClient } from "../supabase/server";
export async function checkEmail(email: string) {
export async function checkEmail(email: string, investigation_id: string) {
const url = `http://localhost:5000/scan/`;
const response = await fetch(url, {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"email": email
})
const response = await fetch(url, {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
);
return response.json();
body: JSON.stringify({
"email": email,
"investigation_id": investigation_id
})
},
);
return response.json();
}
async function checkBreachedAccount(account: string | number | boolean, apiKey: string, appName: string) {