完成漏洞的review,scan的基本curd

This commit is contained in:
yyhuni
2026-01-14 08:21:34 +08:00
parent 3760684b64
commit e3003f33f9
25 changed files with 1672 additions and 310 deletions

View File

@@ -204,7 +204,8 @@ export const createScanHistoryColumns = ({
enableHiding: false,
},
{
accessorKey: "targetName",
accessorKey: "target",
accessorFn: (row) => row.target?.name,
size: 350,
minSize: 100,
meta: { title: t.columns.target },
@@ -212,8 +213,8 @@ export const createScanHistoryColumns = ({
<DataTableColumnHeader column={column} title={t.columns.target} />
),
cell: ({ row }) => {
const targetName = row.getValue("targetName") as string
const targetId = row.original.target
const targetName = row.original.target?.name
const targetId = row.original.targetId
return (
<div className="flex-1 min-w-0">
@@ -239,7 +240,8 @@ export const createScanHistoryColumns = ({
},
},
{
accessorKey: "summary",
accessorKey: "cachedStats",
accessorFn: (row) => row.cachedStats,
meta: { title: t.columns.summary },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t.columns.summary} />
@@ -247,25 +249,13 @@ export const createScanHistoryColumns = ({
size: 290,
minSize: 150,
cell: ({ row }) => {
const summary = (row.getValue("summary") as {
subdomains: number
websites: number
endpoints: number
ips: number
vulnerabilities: {
total: number
critical: number
high: number
medium: number
low: number
}
}) || {}
const stats = row.original.cachedStats || {}
const subdomains = summary?.subdomains ?? 0
const websites = summary?.websites ?? 0
const endpoints = summary?.endpoints ?? 0
const ips = summary?.ips ?? 0
const vulns = summary?.vulnerabilities?.total ?? 0
const subdomains = stats.subdomainsCount ?? 0
const websites = stats.websitesCount ?? 0
const endpoints = stats.endpointsCount ?? 0
const ips = stats.ipsCount ?? 0
const vulns = stats.vulnsTotal ?? 0
const badges: React.ReactNode[] = []
@@ -368,7 +358,7 @@ export const createScanHistoryColumns = ({
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs font-medium">
{summary?.vulnerabilities?.critical ?? 0} Critical, {summary?.vulnerabilities?.high ?? 0} High, {summary?.vulnerabilities?.medium ?? 0} Medium {t.summary.vulnerabilities}
{stats.vulnsCritical ?? 0} Critical, {stats.vulnsHigh ?? 0} High, {stats.vulnsMedium ?? 0} Medium {t.summary.vulnerabilities}
</p>
</TooltipContent>
</Tooltip>
@@ -578,9 +568,9 @@ export const createScanHistoryColumns = ({
},
]
// Filter out targetName column if hideTargetColumn is true
// Filter out target column if hideTargetColumn is true
if (hideTargetColumn) {
return columns.filter(col => (col as any).accessorKey !== 'targetName')
return columns.filter(col => (col as any).accessorKey !== 'target')
}
return columns

View File

@@ -3,12 +3,19 @@
import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useTranslations } from "next-intl"
import { IconSearch, IconLoader2 } from "@tabler/icons-react"
import { IconSearch, IconLoader2, IconFilter } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { UnifiedDataTable } from "@/components/ui/data-table"
import type { ScanRecord } from "@/types/scan.types"
import type { ScanRecord, ScanStatus } from "@/types/scan.types"
import type { PaginationInfo } from "@/types/common.types"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface ScanHistoryDataTableProps {
data: ScanRecord[]
@@ -28,6 +35,8 @@ interface ScanHistoryDataTableProps {
hideToolbar?: boolean
hidePagination?: boolean
pageSizeOptions?: number[]
statusFilter?: ScanStatus | "all"
onStatusFilterChange?: (status: ScanStatus | "all") => void
}
/**
@@ -52,6 +61,8 @@ export function ScanHistoryDataTable({
hideToolbar = false,
hidePagination = false,
pageSizeOptions,
statusFilter = "all",
onStatusFilterChange,
}: ScanHistoryDataTableProps) {
const t = useTranslations("common.status")
const tScan = useTranslations("scan.history")
@@ -76,6 +87,16 @@ export function ScanHistoryDataTable({
}
}
// Status options
const statusOptions: { value: ScanStatus | "all"; label: string }[] = [
{ value: "all", label: tScan("allStatus") },
{ value: "running", label: t("running") },
{ value: "completed", label: t("completed") },
{ value: "failed", label: t("failed") },
{ value: "initiated", label: t("pending") },
{ value: "cancelled", label: t("cancelled") },
]
return (
<UnifiedDataTable
data={data}
@@ -101,9 +122,9 @@ export function ScanHistoryDataTable({
emptyMessage={t("noData")}
// Auto column sizing
enableAutoColumnSizing
// Custom search box
// Custom search box and status filter
toolbarLeft={
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2">
<Input
placeholder={searchPlaceholder || tScan("searchPlaceholder")}
value={localSearchValue}
@@ -118,6 +139,24 @@ export function ScanHistoryDataTable({
<IconSearch className="h-4 w-4" />
)}
</Button>
{onStatusFilterChange && (
<Select
value={statusFilter}
onValueChange={(value) => onStatusFilterChange(value as ScanStatus | "all")}
>
<SelectTrigger size="sm" className="w-[140px]">
<IconFilter className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
}
/>

View File

@@ -6,7 +6,7 @@ import { useTranslations, useLocale } from "next-intl"
import { ScanHistoryDataTable } from "./scan-history-data-table"
import { createScanHistoryColumns } from "./scan-history-columns"
import { getDateLocale } from "@/lib/date-utils"
import type { ScanRecord } from "@/types/scan.types"
import type { ScanRecord, ScanStatus } from "@/types/scan.types"
import type { ColumnDef } from "@tanstack/react-table"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import {
@@ -108,6 +108,9 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
// Search state
const [searchQuery, setSearchQuery] = useState("")
const [isSearching, setIsSearching] = useState(false)
// Status filter state
const [statusFilter, setStatusFilter] = useState<ScanStatus | "all">("all")
const handleSearchChange = (value: string) => {
setIsSearching(true)
@@ -115,12 +118,18 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
const handleStatusFilterChange = (status: ScanStatus | "all") => {
setStatusFilter(status)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
// Get scan list data
const { data, isLoading, isFetching, error } = useScans({
page: pagination.pageIndex + 1, // API page numbers start from 1
pageSize: pagination.pageSize,
search: searchQuery || undefined,
target: targetId,
status: statusFilter === "all" ? undefined : statusFilter,
})
// Reset search state when request completes
@@ -195,7 +204,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
try {
await deleteMutation.mutateAsync(scanToDelete.id)
toast.success(tToast("deletedScanRecord", { name: scanToDelete.targetName }))
toast.success(tToast("deletedScanRecord", { name: scanToDelete.target?.name ?? "" }))
} catch (error) {
toast.error(tToast("deleteFailed"))
console.error('Delete failed:', error)
@@ -226,7 +235,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
try {
await stopMutation.mutateAsync(scanToStop.id)
toast.success(tToast("stoppedScan", { name: scanToStop.targetName }))
toast.success(tToast("stoppedScan", { name: scanToStop.target?.name ?? "" }))
} catch (error) {
toast.error(tToast("stopFailed"))
console.error('Stop scan failed:', error)
@@ -339,6 +348,8 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
hideToolbar={hideToolbar}
pageSizeOptions={pageSizeOptions}
hidePagination={hidePagination}
statusFilter={statusFilter}
onStatusFilterChange={handleStatusFilterChange}
/>
{/* Delete confirmation dialog */}
@@ -347,7 +358,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
<AlertDialogHeader>
<AlertDialogTitle>{tConfirm("deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{tConfirm("deleteScanMessage", { name: scanToDelete?.targetName ?? "" })}
{tConfirm("deleteScanMessage", { name: scanToDelete?.target?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -376,7 +387,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
<ul className="text-sm space-y-1">
{selectedScans.map((scan) => (
<li key={scan.id} className="flex items-center justify-between">
<span className="font-medium">{scan.targetName}</span>
<span className="font-medium">{scan.target?.name}</span>
<span className="text-muted-foreground text-xs">{scan.engineNames?.join(", ") || "-"}</span>
</li>
))}
@@ -400,7 +411,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
<AlertDialogHeader>
<AlertDialogTitle>{tConfirm("stopScanTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{tConfirm("stopScanMessage", { name: scanToStop?.targetName ?? "" })}
{tConfirm("stopScanMessage", { name: scanToStop?.target?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -206,13 +206,28 @@ export function ScanOverview({ scanId }: ScanOverviewProps) {
// Use type assertion for extended properties
const scanAny = scan as any
const summary = scanAny.summary || {}
const vulnSummary = summary.vulnerabilities || { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
// Support both cachedStats (Go) and summary (Python) formats
const stats = scan.cachedStats || scanAny.summary || {}
const summary = {
subdomains: stats.subdomainsCount ?? stats.subdomains ?? 0,
websites: stats.websitesCount ?? stats.websites ?? 0,
endpoints: stats.endpointsCount ?? stats.endpoints ?? 0,
ips: stats.ipsCount ?? stats.ips ?? 0,
directories: stats.directoriesCount ?? stats.directories ?? 0,
screenshots: stats.screenshotsCount ?? stats.screenshots ?? 0,
}
const vulnSummary = stats.vulnerabilities || {
total: stats.vulnsTotal ?? 0,
critical: stats.vulnsCritical ?? 0,
high: stats.vulnsHigh ?? 0,
medium: stats.vulnsMedium ?? 0,
low: stats.vulnsLow ?? 0,
}
const statusIconConfig = getStatusIcon(scan.status)
const StatusIcon = statusIconConfig.icon
const statusStyle = SCAN_STATUS_STYLES[scan.status] || "bg-muted text-muted-foreground"
const targetId = scanAny.target // Target ID
const targetName = scan.targetName // Target name
const targetId = scan.targetId
const targetName = scan.target?.name
const startedAt = scanAny.startedAt || scan.createdAt
const completedAt = scanAny.completedAt

View File

@@ -40,7 +40,11 @@ interface StageDetail {
*/
export interface ScanProgressData {
id: number
targetName: string
target?: {
id: number
name: string
type: string
}
engineNames: string[]
status: string
progress: number
@@ -225,7 +229,7 @@ export function ScanProgressDialog({
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t("target")}</span>
<span className="font-medium">{data.targetName}</span>
<span className="font-medium">{data.target?.name}</span>
</div>
<div className="flex items-start justify-between text-sm gap-4">
<span className="text-muted-foreground shrink-0">{t("engine")}</span>
@@ -322,25 +326,25 @@ function formatDateTime(isoString?: string, locale: string = "zh"): string {
}
}
/** Get stage result count from summary */
function getStageResultCount(stageName: string, summary: ScanRecord["summary"]): number | undefined {
if (!summary) return undefined
/** Get stage result count from cachedStats */
function getStageResultCount(stageName: string, stats: ScanRecord["cachedStats"]): number | undefined {
if (!stats) return undefined
switch (stageName) {
case "subdomain_discovery":
case "subdomainDiscovery":
return summary.subdomains
return stats.subdomainsCount
case "site_scan":
case "siteScan":
return summary.websites
return stats.websitesCount
case "directory_scan":
case "directoryScan":
return summary.directories
return stats.directoriesCount
case "url_fetch":
case "urlFetch":
return summary.endpoints
return stats.endpointsCount
case "vuln_scan":
case "vulnScan":
return summary.vulnerabilities?.total
return stats.vulnsTotal
default:
return undefined
}
@@ -378,7 +382,7 @@ export function buildScanProgressData(scan: ScanRecord): ScanProgressData {
for (const [stageName, progress] of sortedEntries) {
const resultCount = progress.status === "completed"
? getStageResultCount(stageName, scan.summary)
? getStageResultCount(stageName, scan.cachedStats)
: undefined
stages.push({
@@ -393,7 +397,7 @@ export function buildScanProgressData(scan: ScanRecord): ScanProgressData {
return {
id: scan.id,
targetName: scan.targetName,
target: scan.target,
engineNames: scan.engineNames || [],
status: scan.status,
progress: scan.progress,

View File

@@ -14,6 +14,7 @@ import type { Vulnerability, VulnerabilitySeverity } from "@/types/vulnerability
// Translation type definitions
export interface VulnerabilityTranslations {
columns: {
status?: string
severity: string
source: string
vulnType: string
@@ -90,34 +91,27 @@ export function createVulnerabilityColumns({
},
{
id: "reviewStatus",
size: 40,
minSize: 40,
maxSize: 40,
meta: { title: t.columns.status || "状态" },
size: 90,
minSize: 80,
maxSize: 100,
enableResizing: false,
header: "",
header: t.columns.status || "状态",
cell: ({ row }) => {
const isReviewed = row.original.isReviewed
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onToggleReview?.(row.original)}
className="p-1 hover:bg-muted rounded transition-colors"
disabled={!onToggleReview}
>
<span
className={`inline-block w-2 h-2 rounded-full transition-colors ${
isReviewed
? "bg-muted-foreground/30"
: "bg-blue-500"
}`}
/>
</button>
</TooltipTrigger>
<TooltipContent>
{isReviewed ? t.tooltips.reviewed : t.tooltips.pending}
</TooltipContent>
</Tooltip>
<Badge
variant="outline"
className={`cursor-pointer transition-all hover:opacity-80 ${
isReviewed
? "bg-muted/50 text-muted-foreground border-muted-foreground/20"
: "bg-blue-500/10 text-blue-600 border-blue-500/20 dark:text-blue-400"
} ${!onToggleReview ? "cursor-default" : ""}`}
onClick={() => onToggleReview?.(row.original)}
>
{isReviewed ? t.tooltips.reviewed : t.tooltips.pending}
</Badge>
)
},
enableSorting: false,

View File

@@ -3,19 +3,28 @@
import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useTranslations } from "next-intl"
import { CheckCircle, Circle } from "lucide-react"
import { ChevronDown, CheckCircle, Circle, X } from "lucide-react"
import { UnifiedDataTable } from "@/components/ui/data-table"
import { PREDEFINED_FIELDS, type FilterField } from "@/components/common/smart-filter-input"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import type { Vulnerability } from "@/types/vulnerability.types"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import type { Vulnerability, VulnerabilitySeverity } from "@/types/vulnerability.types"
import type { PaginationInfo } from "@/types/common.types"
import type { DownloadOption } from "@/types/data-table.types"
// Review filter type
export type ReviewFilter = "all" | "pending" | "reviewed"
// Severity filter type
export type SeverityFilter = VulnerabilitySeverity | "all"
// Vulnerability page filter fields
const VULNERABILITY_FILTER_FIELDS: FilterField[] = [
{ key: "type", label: "Type", description: "Vulnerability type" },
@@ -32,6 +41,7 @@ const VULNERABILITY_FILTER_EXAMPLES = [
'type="xss" && url="/api/*"',
]
interface VulnerabilitiesDataTableProps {
data: Vulnerability[]
columns: ColumnDef<Vulnerability>[]
@@ -53,6 +63,13 @@ interface VulnerabilitiesDataTableProps {
selectedRows?: Vulnerability[]
onBulkMarkAsReviewed?: () => void
onBulkMarkAsPending?: () => void
// New: severity filter
severityFilter?: SeverityFilter
onSeverityFilterChange?: (filter: SeverityFilter) => void
// New: source filter
sourceFilter?: string
onSourceFilterChange?: (source: string) => void
availableSources?: string[]
}
export function VulnerabilitiesDataTable({
@@ -75,11 +92,17 @@ export function VulnerabilitiesDataTable({
selectedRows = [],
onBulkMarkAsReviewed,
onBulkMarkAsPending,
severityFilter = "all",
onSeverityFilterChange,
sourceFilter = "all",
onSourceFilterChange,
availableSources = [],
}: VulnerabilitiesDataTableProps) {
const t = useTranslations("common.status")
const tDownload = useTranslations("common.download")
const tActions = useTranslations("common.actions")
const tVuln = useTranslations("vulnerabilities")
const tSeverity = useTranslations("severity")
// Handle smart filter search
const handleFilterSearch = (rawQuery: string) => {
@@ -104,90 +127,164 @@ export function VulnerabilitiesDataTable({
})
}
// Custom toolbar content for review filter tabs and bulk actions
const reviewToolbarContent = (
<div className="flex items-center gap-4">
// Severity options for dropdown
const severityOptions: SeverityFilter[] = ["all", "critical", "high", "medium", "low", "info"]
// Right toolbar content - bulk actions, filters and review tabs
const rightToolbarContent = (
<>
{/* Severity dropdown filter */}
{onSeverityFilterChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
{severityFilter === "all" ? tVuln("severity") : tSeverity(severityFilter)}
<ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{severityOptions.map((sev) => (
<DropdownMenuCheckboxItem
key={sev}
checked={severityFilter === sev}
onCheckedChange={() => onSeverityFilterChange(sev)}
>
{sev === "all" ? tVuln("reviewStatus.all") : tSeverity(sev)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Source dropdown filter */}
{onSourceFilterChange && availableSources.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
{sourceFilter === "all" ? tVuln("source") : sourceFilter}
<ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuCheckboxItem
checked={sourceFilter === "all"}
onCheckedChange={() => onSourceFilterChange("all")}
>
{tVuln("reviewStatus.all")}
</DropdownMenuCheckboxItem>
{availableSources.map((src) => (
<DropdownMenuCheckboxItem
key={src}
checked={sourceFilter === src}
onCheckedChange={() => onSourceFilterChange(src)}
>
{src}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Review filter tabs */}
{onReviewFilterChange && (
<Tabs value={reviewFilter} onValueChange={(v) => onReviewFilterChange(v as ReviewFilter)}>
<TabsList className="h-8">
<TabsTrigger value="all" className="text-xs px-3 h-7">
<TabsList>
<TabsTrigger value="all">
{tVuln("reviewStatus.all")}
</TabsTrigger>
<TabsTrigger value="pending" className="text-xs px-3 h-7">
<TabsTrigger value="pending">
{tVuln("reviewStatus.pending")}
{pendingCount > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-xs">
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{pendingCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="reviewed" className="text-xs px-3 h-7">
<TabsTrigger value="reviewed">
{tVuln("reviewStatus.reviewed")}
</TabsTrigger>
</TabsList>
</Tabs>
)}
</>
)
{/* Bulk review actions */}
{selectedRows.length > 0 && (
<div className="flex items-center gap-2">
{onBulkMarkAsReviewed && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={onBulkMarkAsReviewed}
>
<CheckCircle className="h-4 w-4 mr-1" />
{tVuln("markAsReviewed")}
</Button>
)}
{onBulkMarkAsPending && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={onBulkMarkAsPending}
>
<Circle className="h-4 w-4 mr-1" />
{tVuln("markAsPending")}
</Button>
)}
</div>
)}
// Floating action bar for bulk operations
const floatingActionBar = selectedRows.length > 0 && (onBulkMarkAsReviewed || onBulkMarkAsPending) && (
<div className="fixed bottom-6 left-[calc(50vw+var(--sidebar-width,14rem)/2)] -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 fade-in duration-200">
<div className="flex items-center gap-3 bg-background border rounded-lg shadow-lg px-4 py-2.5">
<span className="text-sm text-muted-foreground">
{tVuln("selected", { count: selectedRows.length })}
</span>
<div className="h-4 w-px bg-border" />
{onBulkMarkAsReviewed && (
<Button
variant="outline"
size="sm"
onClick={onBulkMarkAsReviewed}
className="h-8"
>
<CheckCircle className="h-4 w-4 mr-1.5" />
{tVuln("markAsReviewed")}
</Button>
)}
{onBulkMarkAsPending && (
<Button
variant="outline"
size="sm"
onClick={onBulkMarkAsPending}
className="h-8"
>
<Circle className="h-4 w-4 mr-1.5" />
{tVuln("markAsPending")}
</Button>
)}
{onSelectionChange && (
<Button
variant="ghost"
size="icon"
onClick={() => onSelectionChange([])}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)
return (
<UnifiedDataTable
data={data}
columns={columns}
getRowId={(row) => String(row.id)}
// Pagination
pagination={pagination}
setPagination={setPagination}
paginationInfo={paginationInfo}
onPaginationChange={onPaginationChange}
// Smart filter
searchMode="smart"
searchValue={filterValue}
onSearch={handleFilterSearch}
filterFields={VULNERABILITY_FILTER_FIELDS}
filterExamples={VULNERABILITY_FILTER_EXAMPLES}
// Selection
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel={tActions("delete")}
showAddButton={false}
// Download
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// Toolbar
hideToolbar={hideToolbar}
toolbarLeft={reviewToolbarContent}
// Empty state
emptyMessage={t("noData")}
/>
<>
<UnifiedDataTable
data={data}
columns={columns}
getRowId={(row) => String(row.id)}
// Pagination
pagination={pagination}
setPagination={setPagination}
paginationInfo={paginationInfo}
onPaginationChange={onPaginationChange}
// Smart filter
searchMode="smart"
searchValue={filterValue}
onSearch={handleFilterSearch}
filterFields={VULNERABILITY_FILTER_FIELDS}
filterExamples={VULNERABILITY_FILTER_EXAMPLES}
// Selection
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel={tActions("delete")}
showAddButton={false}
// Download
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// Toolbar
hideToolbar={hideToolbar}
toolbarRight={rightToolbarContent}
// Empty state
emptyMessage={t("noData")}
/>
{floatingActionBar}
</>
)
}

View File

@@ -67,6 +67,7 @@ export function VulnerabilitiesDetailView({
// Build translation object
const translations = useMemo(() => ({
columns: {
status: tColumns("common.status"),
severity: tColumns("vulnerability.severity"),
source: tColumns("vulnerability.source"),
vulnType: tColumns("vulnerability.vulnType"),
@@ -160,6 +161,7 @@ export function VulnerabilitiesDetailView({
return vulnerabilities.filter(v => !v.isReviewed).length
}, [vulnerabilities])
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString(getDateLocale(locale), {
year: "numeric",

View File

@@ -723,6 +723,7 @@
"duration": "Duration",
"status": "Status",
"searchPlaceholder": "Search target name...",
"allStatus": "All Status",
"loadFailed": "Failed to load scan history",
"retry": "Retry",
"taskId": "Scan Task ID: {id}",
@@ -1959,6 +1960,8 @@
"vulnerabilities": {
"title": "Vulnerability Management",
"description": "View and manage all vulnerabilities found by scans",
"severity": "Severity",
"source": "Source",
"reviewStatus": {
"all": "All",
"pending": "Pending",
@@ -1972,8 +1975,9 @@
"bulkUnreviewSuccess": "{count} vulnerabilities marked as pending",
"bulkReviewError": "Failed to bulk mark as reviewed",
"bulkUnreviewError": "Failed to bulk mark as pending",
"markAsReviewed": "Mark as Reviewed",
"markAsPending": "Mark as Pending",
"markAsReviewed": "Mark Reviewed",
"markAsPending": "Mark Pending",
"selected": "{count} selected",
"tooltips": {
"reviewed": "Reviewed",
"pending": "Pending review"

View File

@@ -737,6 +737,7 @@
"duration": "耗时",
"status": "状态",
"searchPlaceholder": "搜索目标名称...",
"allStatus": "全部状态",
"loadFailed": "加载扫描历史失败",
"retry": "重试",
"taskId": "扫描任务 ID{id}",
@@ -1973,6 +1974,8 @@
"vulnerabilities": {
"title": "漏洞管理",
"description": "查看和管理所有扫描发现的漏洞",
"severity": "严重程度",
"source": "来源",
"reviewStatus": {
"all": "全部",
"pending": "待审查",
@@ -1986,8 +1989,9 @@
"bulkUnreviewSuccess": "已将 {count} 个漏洞标记为待审查",
"bulkReviewError": "批量标记已审查失败",
"bulkUnreviewError": "批量标记待审查失败",
"markAsReviewed": "标记已审",
"markAsPending": "标记待审",
"markAsReviewed": "标记已审",
"markAsPending": "标记待审",
"selected": "已选 {count} 项",
"tooltips": {
"reviewed": "已审查",
"pending": "待审查"

View File

@@ -4,50 +4,50 @@ import type { ScanStatistics } from '@/services/scan.service'
export const mockScans: ScanRecord[] = [
{
id: 1,
target: 1,
targetName: 'acme.com',
targetId: 1,
target: { id: 1, name: 'acme.com', type: 'domain' },
workerName: 'worker-01',
summary: {
subdomains: 156,
websites: 89,
directories: 234,
endpoints: 2341,
ips: 45,
vulnerabilities: {
total: 23,
critical: 1,
high: 4,
medium: 8,
low: 10,
},
cachedStats: {
subdomainsCount: 156,
websitesCount: 89,
directoriesCount: 234,
endpointsCount: 2341,
ipsCount: 45,
screenshotsCount: 50,
vulnsTotal: 23,
vulnsCritical: 1,
vulnsHigh: 4,
vulnsMedium: 8,
vulnsLow: 10,
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
scanMode: 'full',
createdAt: '2024-12-28T10:00:00Z',
status: 'completed',
progress: 100,
},
{
id: 2,
target: 2,
targetName: 'acme.io',
targetId: 2,
target: { id: 2, name: 'acme.io', type: 'domain' },
workerName: 'worker-02',
summary: {
subdomains: 78,
websites: 45,
directories: 123,
endpoints: 892,
ips: 23,
vulnerabilities: {
total: 12,
critical: 0,
high: 2,
medium: 5,
low: 5,
},
cachedStats: {
subdomainsCount: 78,
websitesCount: 45,
directoriesCount: 123,
endpointsCount: 892,
ipsCount: 23,
screenshotsCount: 30,
vulnsTotal: 12,
vulnsCritical: 0,
vulnsHigh: 2,
vulnsMedium: 5,
vulnsLow: 5,
},
engineIds: [1, 2],
engineNames: ['Subdomain Discovery', 'Web Crawling'],
scanMode: 'full',
createdAt: '2024-12-27T14:30:00Z',
status: 'running',
progress: 65,
@@ -69,50 +69,50 @@ export const mockScans: ScanRecord[] = [
},
{
id: 3,
target: 3,
targetName: 'techstart.io',
targetId: 3,
target: { id: 3, name: 'techstart.io', type: 'domain' },
workerName: 'worker-01',
summary: {
subdomains: 45,
websites: 28,
directories: 89,
endpoints: 567,
ips: 12,
vulnerabilities: {
total: 8,
critical: 0,
high: 1,
medium: 3,
low: 4,
},
cachedStats: {
subdomainsCount: 45,
websitesCount: 28,
directoriesCount: 89,
endpointsCount: 567,
ipsCount: 12,
screenshotsCount: 20,
vulnsTotal: 8,
vulnsCritical: 0,
vulnsHigh: 1,
vulnsMedium: 3,
vulnsLow: 4,
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
scanMode: 'full',
createdAt: '2024-12-26T08:45:00Z',
status: 'completed',
progress: 100,
},
{
id: 4,
target: 4,
targetName: 'globalfinance.com',
targetId: 4,
target: { id: 4, name: 'globalfinance.com', type: 'domain' },
workerName: 'worker-03',
summary: {
subdomains: 0,
websites: 0,
directories: 0,
endpoints: 0,
ips: 0,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
cachedStats: {
subdomainsCount: 0,
websitesCount: 0,
directoriesCount: 0,
endpointsCount: 0,
ipsCount: 0,
screenshotsCount: 0,
vulnsTotal: 0,
vulnsCritical: 0,
vulnsHigh: 0,
vulnsMedium: 0,
vulnsLow: 0,
},
engineIds: [1],
engineNames: ['Subdomain Discovery'],
scanMode: 'full',
createdAt: '2024-12-25T16:20:00Z',
status: 'failed',
progress: 15,
@@ -120,25 +120,25 @@ export const mockScans: ScanRecord[] = [
},
{
id: 5,
target: 6,
targetName: 'healthcareplus.com',
targetId: 6,
target: { id: 6, name: 'healthcareplus.com', type: 'domain' },
workerName: 'worker-02',
summary: {
subdomains: 34,
websites: 0,
directories: 0,
endpoints: 0,
ips: 8,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
cachedStats: {
subdomainsCount: 34,
websitesCount: 0,
directoriesCount: 0,
endpointsCount: 0,
ipsCount: 8,
screenshotsCount: 0,
vulnsTotal: 0,
vulnsCritical: 0,
vulnsHigh: 0,
vulnsMedium: 0,
vulnsLow: 0,
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
scanMode: 'full',
createdAt: '2024-12-29T09:00:00Z',
status: 'running',
progress: 25,
@@ -161,75 +161,75 @@ export const mockScans: ScanRecord[] = [
},
{
id: 6,
target: 7,
targetName: 'edutech.io',
targetId: 7,
target: { id: 7, name: 'edutech.io', type: 'domain' },
workerName: null,
summary: {
subdomains: 0,
websites: 0,
directories: 0,
endpoints: 0,
ips: 0,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
cachedStats: {
subdomainsCount: 0,
websitesCount: 0,
directoriesCount: 0,
endpointsCount: 0,
ipsCount: 0,
screenshotsCount: 0,
vulnsTotal: 0,
vulnsCritical: 0,
vulnsHigh: 0,
vulnsMedium: 0,
vulnsLow: 0,
},
engineIds: [1, 2],
engineNames: ['Subdomain Discovery', 'Web Crawling'],
scanMode: 'full',
createdAt: '2024-12-29T10:30:00Z',
status: 'initiated',
progress: 0,
},
{
id: 7,
target: 8,
targetName: 'retailmax.com',
targetId: 8,
target: { id: 8, name: 'retailmax.com', type: 'domain' },
workerName: 'worker-01',
summary: {
subdomains: 89,
websites: 56,
directories: 178,
endpoints: 1234,
ips: 28,
vulnerabilities: {
total: 15,
critical: 0,
high: 3,
medium: 6,
low: 6,
},
cachedStats: {
subdomainsCount: 89,
websitesCount: 56,
directoriesCount: 178,
endpointsCount: 1234,
ipsCount: 28,
screenshotsCount: 40,
vulnsTotal: 15,
vulnsCritical: 0,
vulnsHigh: 3,
vulnsMedium: 6,
vulnsLow: 6,
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
scanMode: 'full',
createdAt: '2024-12-21T10:45:00Z',
status: 'completed',
progress: 100,
},
{
id: 8,
target: 11,
targetName: 'mediastream.tv',
targetId: 11,
target: { id: 11, name: 'mediastream.tv', type: 'domain' },
workerName: 'worker-02',
summary: {
subdomains: 67,
websites: 0,
directories: 0,
endpoints: 0,
ips: 15,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
cachedStats: {
subdomainsCount: 67,
websitesCount: 0,
directoriesCount: 0,
endpointsCount: 0,
ipsCount: 15,
screenshotsCount: 0,
vulnsTotal: 0,
vulnsCritical: 0,
vulnsHigh: 0,
vulnsMedium: 0,
vulnsLow: 0,
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
scanMode: 'full',
createdAt: '2024-12-29T08:00:00Z',
status: 'running',
progress: 45,
@@ -286,7 +286,7 @@ export function getMockScans(params?: {
if (search) {
filtered = filtered.filter(scan =>
scan.targetName.toLowerCase().includes(search)
scan.target?.name?.toLowerCase().includes(search)
)
}

View File

@@ -32,34 +32,51 @@ export interface StageProgressItem {
*/
export type StageProgress = Record<string, StageProgressItem>
/**
* Target brief info in scan response
*/
export interface ScanTargetBrief {
id: number
name: string
type: string
}
/**
* Cached statistics in scan response
*/
export interface ScanCachedStats {
subdomainsCount: number
websitesCount: number
endpointsCount: number
ipsCount: number
directoriesCount: number
screenshotsCount: number
vulnsTotal: number
vulnsCritical: number
vulnsHigh: number
vulnsMedium: number
vulnsLow: number
}
export interface ScanRecord {
id: number
target?: number // Target ID (corresponds to backend target)
targetName: string // Target name (corresponds to backend targetName)
workerName?: string | null // Worker node name (corresponds to backend worker_name)
summary: {
subdomains: number
websites: number
directories: number
endpoints: number
ips: number
vulnerabilities: {
total: number
critical: number
high: number
medium: number
low: number
}
}
engineIds: number[] // Engine ID list (corresponds to backend engine_ids)
engineNames: string[] // Engine name list (corresponds to backend engine_names)
createdAt: string // Creation time (corresponds to backend createdAt)
targetId: number // Target ID
target?: ScanTargetBrief // Target info (nested object)
workerName?: string | null // Worker node name
cachedStats?: ScanCachedStats // Cached statistics
engineIds: number[] // Engine ID list
engineNames: string[] // Engine name list
scanMode: string // Scan mode
createdAt: string // Creation time
stoppedAt?: string // Stop time
status: ScanStatus
errorMessage?: string // Error message (corresponds to backend errorMessage, has value when failed)
errorMessage?: string // Error message (has value when failed)
progress: number // 0-100
currentStage?: ScanStage // Current scan stage (only has value in running status)
stageProgress?: StageProgress // Stage progress details
yamlConfiguration?: string // YAML configuration string
yamlConfiguration?: string // YAML configuration string
resultsDir?: string // Results directory
workerId?: number // Worker ID
}
export interface GetScansParams {

View File

@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"math/rand"
@@ -128,12 +129,21 @@ func main() {
os.Exit(1)
}
// Create scans for targets
if err := createScans(db, targets, 3); err != nil {
fmt.Printf("❌ Failed to create scans: %v\n", err)
os.Exit(1)
}
fmt.Println("\n✅ Test data generation completed!")
}
func clearData(db *gorm.DB) error {
// Delete in order to respect foreign key constraints
tables := []string{
"scan_log",
"scan_input_target",
"scan",
"vulnerability",
"screenshot",
"host_port_mapping",
@@ -947,3 +957,125 @@ func createVulnerabilities(db *gorm.DB, targets []model.Target, vulnsPerTarget i
fmt.Printf(" ✓ Created %d vulnerabilities\n", len(vulns))
return nil
}
func createScans(db *gorm.DB, targets []model.Target, scansPerTarget int) error {
totalCount := len(targets) * scansPerTarget
fmt.Printf("🔍 Creating %d scans (%d per target)...\n", totalCount, scansPerTarget)
if len(targets) == 0 {
return nil
}
// Scan data templates
statuses := []string{"completed", "completed", "completed", "running", "failed"}
scanModes := []string{"full", "full", "quick"}
stages := []string{"subdomain_discovery", "port_scan", "web_discovery", "vulnerability_scan", ""}
engineNames := [][]string{
{"Default Engine"},
{"Fast Scanner", "Deep Scanner"},
{"Nuclei Engine"},
{"Full Recon"},
}
errorMessages := []string{
"",
"Connection timeout to target",
"Worker node unavailable",
"Rate limit exceeded",
}
// Build all scans in memory first
scans := make([]model.Scan, 0, totalCount)
for _, target := range targets {
for i := 0; i < scansPerTarget; i++ {
status := statuses[i%len(statuses)]
scanMode := scanModes[i%len(scanModes)]
currentStage := stages[i%len(stages)]
// Calculate progress based on status
var progress int
switch status {
case "completed":
progress = 100
case "running":
progress = 30 + rand.Intn(60)
case "failed":
progress = rand.Intn(50)
default:
progress = 0
}
// Engine IDs and names
engineIDs := pq.Int64Array{1}
names := engineNames[i%len(engineNames)]
namesJSON, _ := json.Marshal(names)
// Error message (only for failed scans)
var errorMsg string
if status == "failed" {
errorMsg = errorMessages[1+rand.Intn(len(errorMessages)-1)]
}
// Created time
daysAgo := i * 7 // Each scan is 7 days apart
createdAt := time.Now().AddDate(0, 0, -daysAgo)
// Stopped time (for completed/failed scans)
var stoppedAt *time.Time
if status == "completed" || status == "failed" {
t := createdAt.Add(time.Duration(30+rand.Intn(60)) * time.Minute)
stoppedAt = &t
}
// Cached stats (random values for demonstration)
subdomainsCount := rand.Intn(50)
websitesCount := rand.Intn(30)
endpointsCount := rand.Intn(100)
ipsCount := rand.Intn(20)
directoriesCount := rand.Intn(40)
screenshotsCount := rand.Intn(20)
vulnsCritical := rand.Intn(3)
vulnsHigh := rand.Intn(5)
vulnsMedium := rand.Intn(10)
vulnsLow := rand.Intn(15)
vulnsTotal := vulnsCritical + vulnsHigh + vulnsMedium + vulnsLow
scan := model.Scan{
TargetID: target.ID,
EngineIDs: engineIDs,
EngineNames: namesJSON,
YamlConfiguration: "# Default scan configuration\nthreads: 10\ntimeout: 30s",
ScanMode: scanMode,
Status: status,
Progress: progress,
CurrentStage: currentStage,
ErrorMessage: errorMsg,
CreatedAt: createdAt,
StoppedAt: stoppedAt,
CachedSubdomainsCount: subdomainsCount,
CachedWebsitesCount: websitesCount,
CachedEndpointsCount: endpointsCount,
CachedIPsCount: ipsCount,
CachedDirectoriesCount: directoriesCount,
CachedScreenshotsCount: screenshotsCount,
CachedVulnsTotal: vulnsTotal,
CachedVulnsCritical: vulnsCritical,
CachedVulnsHigh: vulnsHigh,
CachedVulnsMedium: vulnsMedium,
CachedVulnsLow: vulnsLow,
}
scans = append(scans, scan)
}
}
// Batch insert
if err := db.CreateInBatches(scans, 100).Error; err != nil {
return err
}
fmt.Printf(" ✓ Created %d scans\n", len(scans))
return nil
}

View File

@@ -139,6 +139,8 @@ func main() {
ipAddressRepo := repository.NewIPAddressRepository(db)
screenshotRepo := repository.NewScreenshotRepository(db)
vulnerabilityRepo := repository.NewVulnerabilityRepository(db)
scanRepo := repository.NewScanRepository(db)
scanLogRepo := repository.NewScanLogRepository(db)
// Create services
userSvc := service.NewUserService(userRepo)
@@ -153,6 +155,8 @@ func main() {
ipAddressSvc := service.NewIPAddressService(ipAddressRepo, targetRepo)
screenshotSvc := service.NewScreenshotService(screenshotRepo, targetRepo)
vulnerabilitySvc := service.NewVulnerabilityService(vulnerabilityRepo, targetRepo)
scanSvc := service.NewScanService(scanRepo, scanLogRepo, targetRepo, orgRepo)
scanLogSvc := service.NewScanLogService(scanLogRepo, scanRepo)
// Create handlers
healthHandler := handler.NewHealthHandler(db, redisClient)
@@ -169,6 +173,8 @@ func main() {
ipAddressHandler := handler.NewIPAddressHandler(ipAddressSvc)
screenshotHandler := handler.NewScreenshotHandler(screenshotSvc)
vulnerabilityHandler := handler.NewVulnerabilityHandler(vulnerabilitySvc)
scanHandler := handler.NewScanHandler(scanSvc)
scanLogHandler := handler.NewScanLogHandler(scanLogSvc)
// Register health routes
router.GET("/health", healthHandler.Check)
@@ -278,7 +284,7 @@ func main() {
// Vulnerabilities (nested under targets)
protected.GET("/targets/:id/vulnerabilities", vulnerabilityHandler.ListByTarget)
protected.POST("/targets/:id/vulnerabilities/bulk-upsert", vulnerabilityHandler.BulkUpsert)
protected.POST("/targets/:id/vulnerabilities/bulk-create", vulnerabilityHandler.BulkCreate)
// Vulnerabilities (standalone)
protected.POST("/vulnerabilities/bulk-delete", vulnerabilityHandler.BulkDelete)
@@ -302,6 +308,20 @@ func main() {
protected.GET("/wordlists/download", wordlistHandler.Download)
protected.GET("/wordlists/:id/content", wordlistHandler.GetContent)
protected.PUT("/wordlists/:id/content", wordlistHandler.UpdateContent)
// Scans
protected.GET("/scans", scanHandler.List)
protected.GET("/scans/statistics", scanHandler.Statistics)
protected.GET("/scans/:id", scanHandler.GetByID)
protected.DELETE("/scans/:id", scanHandler.Delete)
protected.POST("/scans/:id/stop", scanHandler.Stop)
protected.POST("/scans/initiate", scanHandler.Initiate)
protected.POST("/scans/quick", scanHandler.Quick)
protected.POST("/scans/bulk-delete", scanHandler.BulkDelete)
// Scan Logs (nested under scans)
protected.GET("/scans/:id/logs", scanLogHandler.List)
protected.POST("/scans/:id/logs", scanLogHandler.BulkCreate)
}
}

View File

@@ -0,0 +1,145 @@
package dto
import "time"
// ScanListQuery represents scan list query parameters
type ScanListQuery struct {
PaginationQuery
TargetID int `form:"target" binding:"omitempty"`
Status string `form:"status" binding:"omitempty"`
Search string `form:"search" binding:"omitempty"`
}
// ScanResponse represents scan response
type ScanResponse struct {
ID int `json:"id"`
TargetID int `json:"targetId"`
EngineIDs []int64 `json:"engineIds"`
EngineNames []string `json:"engineNames"`
ScanMode string `json:"scanMode"`
Status string `json:"status"`
Progress int `json:"progress"`
CurrentStage string `json:"currentStage"`
ErrorMessage string `json:"errorMessage,omitempty"`
CreatedAt time.Time `json:"createdAt"`
StoppedAt *time.Time `json:"stoppedAt,omitempty"`
Target *TargetBrief `json:"target,omitempty"`
CachedStats *ScanCachedStats `json:"cachedStats,omitempty"`
}
// TargetBrief represents brief target info for scan response
type TargetBrief struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
// ScanCachedStats represents cached statistics for scan
type ScanCachedStats struct {
SubdomainsCount int `json:"subdomainsCount"`
WebsitesCount int `json:"websitesCount"`
EndpointsCount int `json:"endpointsCount"`
IPsCount int `json:"ipsCount"`
DirectoriesCount int `json:"directoriesCount"`
ScreenshotsCount int `json:"screenshotsCount"`
VulnsTotal int `json:"vulnsTotal"`
VulnsCritical int `json:"vulnsCritical"`
VulnsHigh int `json:"vulnsHigh"`
VulnsMedium int `json:"vulnsMedium"`
VulnsLow int `json:"vulnsLow"`
}
// ScanDetailResponse represents detailed scan response
type ScanDetailResponse struct {
ScanResponse
YamlConfiguration string `json:"yamlConfiguration,omitempty"`
ResultsDir string `json:"resultsDir,omitempty"`
WorkerID *int `json:"workerId,omitempty"`
StageProgress map[string]interface{} `json:"stageProgress,omitempty"`
}
// InitiateScanRequest represents initiate scan request
type InitiateScanRequest struct {
OrganizationID *int `json:"organizationId" binding:"omitempty"`
TargetID *int `json:"targetId" binding:"omitempty"`
EngineIDs []int `json:"engineIds" binding:"required,min=1"`
EngineNames []string `json:"engineNames" binding:"required,min=1"`
Configuration string `json:"configuration" binding:"required"`
}
// QuickScanRequest represents quick scan request
type QuickScanRequest struct {
Targets []QuickScanTarget `json:"targets" binding:"required,min=1"`
EngineIDs []int `json:"engineIds"`
EngineNames []string `json:"engineNames"`
Configuration string `json:"configuration" binding:"required"`
}
// QuickScanTarget represents a target in quick scan
type QuickScanTarget struct {
Name string `json:"name" binding:"required"`
}
// QuickScanResponse represents quick scan response
type QuickScanResponse struct {
Count int `json:"count"`
TargetStats map[string]int `json:"targetStats"`
AssetStats map[string]int `json:"assetStats"`
Errors []string `json:"errors,omitempty"`
Scans []ScanResponse `json:"scans"`
}
// StopScanResponse represents stop scan response
type StopScanResponse struct {
RevokedTaskCount int `json:"revokedTaskCount"`
}
// ScanStatisticsResponse represents scan statistics response
type ScanStatisticsResponse struct {
Total int64 `json:"total"`
Running int64 `json:"running"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
TotalVulns int64 `json:"totalVulns"`
TotalSubdomains int64 `json:"totalSubdomains"`
TotalEndpoints int64 `json:"totalEndpoints"`
TotalWebsites int64 `json:"totalWebsites"`
TotalAssets int64 `json:"totalAssets"`
}
// ScanLogListQuery represents scan log list query parameters (cursor pagination)
type ScanLogListQuery struct {
AfterID int64 `form:"afterId" binding:"omitempty,min=0"`
Limit int `form:"limit" binding:"omitempty,min=1,max=1000"`
}
// ScanLogListResponse represents scan log list response
type ScanLogListResponse struct {
Results []ScanLogResponse `json:"results"`
HasMore bool `json:"hasMore"`
}
// ScanLogResponse represents scan log response
type ScanLogResponse struct {
ID int64 `json:"id"`
ScanID int `json:"scanId"`
Level string `json:"level"`
Content string `json:"content"`
CreatedAt time.Time `json:"createdAt"`
}
// ScanLogItem represents a single log item for bulk create
type ScanLogItem struct {
Level string `json:"level" binding:"required,oneof=info warning error"`
Content string `json:"content" binding:"required"`
}
// BulkCreateScanLogsRequest represents bulk create scan logs request
type BulkCreateScanLogsRequest struct {
Logs []ScanLogItem `json:"logs" binding:"required,min=1,max=1000,dive"`
}
// BulkCreateScanLogsResponse represents bulk create scan logs response
type BulkCreateScanLogsResponse struct {
CreatedCount int `json:"createdCount"`
}

View File

@@ -15,8 +15,8 @@ type VulnerabilityListQuery struct {
IsReviewed *bool `form:"isReviewed"` // nil = all, true = reviewed, false = pending
}
// VulnerabilityUpsertItem represents a single vulnerability for upsert
type VulnerabilityUpsertItem struct {
// VulnerabilityCreateItem represents a single vulnerability for bulk create
type VulnerabilityCreateItem struct {
URL string `json:"url" binding:"required"`
VulnType string `json:"vulnType" binding:"required"`
Severity string `json:"severity"`
@@ -26,9 +26,9 @@ type VulnerabilityUpsertItem struct {
RawOutput map[string]interface{} `json:"rawOutput"`
}
// BulkUpsertVulnerabilitiesRequest represents bulk upsert request
type BulkUpsertVulnerabilitiesRequest struct {
Vulnerabilities []VulnerabilityUpsertItem `json:"vulnerabilities" binding:"required,min=1,max=5000,dive"`
// BulkCreateVulnerabilitiesRequest represents bulk create request
type BulkCreateVulnerabilitiesRequest struct {
Vulnerabilities []VulnerabilityCreateItem `json:"vulnerabilities" binding:"required,min=1,max=5000,dive"`
}
// VulnerabilityResponse represents vulnerability response
@@ -47,9 +47,9 @@ type VulnerabilityResponse struct {
CreatedAt time.Time `json:"createdAt"`
}
// BulkUpsertVulnerabilitiesResponse represents bulk upsert response
type BulkUpsertVulnerabilitiesResponse struct {
UpsertedCount int `json:"upsertedCount"`
// BulkCreateVulnerabilitiesResponse represents bulk create response
type BulkCreateVulnerabilitiesResponse struct {
CreatedCount int `json:"createdCount"`
}
// BulkReviewRequest represents bulk review request

View File

@@ -0,0 +1,166 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/service"
)
// ScanHandler handles scan HTTP requests
type ScanHandler struct {
service *service.ScanService
}
// NewScanHandler creates a new scan handler
func NewScanHandler(service *service.ScanService) *ScanHandler {
return &ScanHandler{service: service}
}
// List returns paginated scans
// GET /api/scans
func (h *ScanHandler) List(c *gin.Context) {
var query dto.ScanListQuery
if !dto.BindQuery(c, &query) {
return
}
scans, total, err := h.service.List(&query)
if err != nil {
dto.InternalError(c, "Failed to list scans")
return
}
// Convert to response DTOs
items := make([]dto.ScanResponse, len(scans))
for i, scan := range scans {
items[i] = *h.service.ToScanResponse(&scan)
}
dto.Paginated(c, items, total, query.GetPage(), query.GetPageSize())
}
// GetByID returns a scan by ID
// GET /api/scans/:id
func (h *ScanHandler) GetByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid scan ID")
return
}
scan, err := h.service.GetByID(id)
if err != nil {
if errors.Is(err, service.ErrScanNotFound) {
dto.NotFound(c, "Scan not found")
return
}
dto.InternalError(c, "Failed to get scan")
return
}
dto.Success(c, h.service.ToScanDetailResponse(scan))
}
// Delete soft deletes a scan
// DELETE /api/scans/:id
func (h *ScanHandler) Delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid scan ID")
return
}
deletedCount, deletedNames, err := h.service.Delete(id)
if err != nil {
if errors.Is(err, service.ErrScanNotFound) {
dto.NotFound(c, "Scan not found")
return
}
dto.InternalError(c, "Failed to delete scan")
return
}
dto.Success(c, gin.H{
"scanId": id,
"deletedCount": deletedCount,
"deletedScans": deletedNames,
})
}
// BulkDelete soft deletes multiple scans
// POST /api/scans/bulk-delete
func (h *ScanHandler) BulkDelete(c *gin.Context) {
var req dto.BulkDeleteRequest
if !dto.BindJSON(c, &req) {
return
}
deletedCount, deletedNames, err := h.service.BulkDelete(req.IDs)
if err != nil {
dto.InternalError(c, "Failed to bulk delete scans")
return
}
dto.Success(c, gin.H{
"deletedCount": deletedCount,
"deletedScans": deletedNames,
})
}
// Statistics returns scan statistics
// GET /api/scans/statistics
func (h *ScanHandler) Statistics(c *gin.Context) {
stats, err := h.service.GetStatistics()
if err != nil {
dto.InternalError(c, "Failed to get scan statistics")
return
}
dto.Success(c, stats)
}
// Stop stops a running scan
// POST /api/scans/:id/stop
func (h *ScanHandler) Stop(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid scan ID")
return
}
revokedCount, err := h.service.Stop(id)
if err != nil {
if errors.Is(err, service.ErrScanNotFound) {
dto.NotFound(c, "Scan not found")
return
}
if errors.Is(err, service.ErrScanCannotStop) {
dto.BadRequest(c, "Cannot stop scan: scan is not running")
return
}
dto.InternalError(c, "Failed to stop scan")
return
}
dto.Success(c, dto.StopScanResponse{
RevokedTaskCount: revokedCount,
})
}
// Initiate starts a new scan
// POST /api/scans/initiate
func (h *ScanHandler) Initiate(c *gin.Context) {
// TODO: Implement when worker integration is ready
dto.Error(c, http.StatusNotImplemented, "NOT_IMPLEMENTED", "Scan initiation is not yet implemented")
}
// Quick starts a quick scan with raw targets
// POST /api/scans/quick
func (h *ScanHandler) Quick(c *gin.Context) {
// TODO: Implement when worker integration is ready
dto.Error(c, http.StatusNotImplemented, "NOT_IMPLEMENTED", "Quick scan is not yet implemented")
}

View File

@@ -0,0 +1,76 @@
package handler
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/service"
)
// ScanLogHandler handles scan log HTTP requests
type ScanLogHandler struct {
svc *service.ScanLogService
}
// NewScanLogHandler creates a new scan log handler
func NewScanLogHandler(svc *service.ScanLogService) *ScanLogHandler {
return &ScanLogHandler{svc: svc}
}
// List returns logs for a scan with cursor pagination
// GET /api/scans/:id/logs?afterId=123&limit=200
func (h *ScanLogHandler) List(c *gin.Context) {
scanID, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid scan ID")
return
}
var query dto.ScanLogListQuery
if !dto.BindQuery(c, &query) {
return
}
resp, err := h.svc.ListByScanID(scanID, &query)
if err != nil {
if errors.Is(err, service.ErrScanNotFound) {
dto.NotFound(c, "Scan not found")
return
}
dto.InternalError(c, "Failed to get scan logs")
return
}
dto.Success(c, resp)
}
// BulkCreate creates multiple logs for a scan (for worker to write logs)
// POST /api/scans/:id/logs
func (h *ScanLogHandler) BulkCreate(c *gin.Context) {
scanID, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid scan ID")
return
}
var req dto.BulkCreateScanLogsRequest
if !dto.BindJSON(c, &req) {
return
}
createdCount, err := h.svc.BulkCreate(scanID, req.Logs)
if err != nil {
if errors.Is(err, service.ErrScanNotFound) {
dto.NotFound(c, "Scan not found")
return
}
dto.InternalError(c, "Failed to create scan logs")
return
}
dto.Created(c, dto.BulkCreateScanLogsResponse{
CreatedCount: createdCount,
})
}

View File

@@ -116,32 +116,32 @@ func (h *VulnerabilityHandler) ListByTarget(c *gin.Context) {
dto.Paginated(c, resp, total, query.GetPage(), query.GetPageSize())
}
// BulkUpsert creates or updates multiple vulnerabilities for a target
// POST /api/targets/:id/vulnerabilities/bulk-upsert/
func (h *VulnerabilityHandler) BulkUpsert(c *gin.Context) {
// BulkCreate creates multiple vulnerabilities for a target
// POST /api/targets/:id/vulnerabilities/bulk-create/
func (h *VulnerabilityHandler) BulkCreate(c *gin.Context) {
targetID, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid target ID")
return
}
var req dto.BulkUpsertVulnerabilitiesRequest
var req dto.BulkCreateVulnerabilitiesRequest
if !dto.BindJSON(c, &req) {
return
}
upsertedCount, err := h.svc.BulkUpsert(targetID, req.Vulnerabilities)
createdCount, err := h.svc.BulkCreate(targetID, req.Vulnerabilities)
if err != nil {
if errors.Is(err, service.ErrTargetNotFound) {
dto.NotFound(c, "Target not found")
return
}
dto.InternalError(c, "Failed to upsert vulnerabilities")
dto.InternalError(c, "Failed to create vulnerabilities")
return
}
dto.Success(c, dto.BulkUpsertVulnerabilitiesResponse{
UpsertedCount: int(upsertedCount),
dto.Success(c, dto.BulkCreateVulnerabilitiesResponse{
CreatedCount: int(createdCount),
})
}

View File

@@ -0,0 +1,248 @@
package repository
import (
"time"
"github.com/xingrin/go-backend/internal/model"
"github.com/xingrin/go-backend/internal/pkg/scope"
"gorm.io/gorm"
)
// ScanRepository handles scan database operations
type ScanRepository struct {
db *gorm.DB
}
// NewScanRepository creates a new scan repository
func NewScanRepository(db *gorm.DB) *ScanRepository {
return &ScanRepository{db: db}
}
// ScanFilterMapping defines field mapping for scan filtering
var ScanFilterMapping = scope.FilterMapping{
"status": {Column: "status"},
"target": {Column: "target_id"},
"targetId": {Column: "target_id"},
}
// Create creates a new scan
func (r *ScanRepository) Create(scan *model.Scan) error {
return r.db.Create(scan).Error
}
// FindByID finds a scan by ID (excluding soft deleted)
func (r *ScanRepository) FindByID(id int) (*model.Scan, error) {
var scan model.Scan
err := r.db.Where("id = ? AND deleted_at IS NULL", id).
First(&scan).Error
if err != nil {
return nil, err
}
return &scan, nil
}
// FindByIDWithTarget finds a scan by ID with target preloaded
func (r *ScanRepository) FindByIDWithTarget(id int) (*model.Scan, error) {
var scan model.Scan
err := r.db.Where("id = ? AND deleted_at IS NULL", id).
Preload("Target").
First(&scan).Error
if err != nil {
return nil, err
}
return &scan, nil
}
// FindAll finds all scans with pagination and filters (excluding soft deleted)
func (r *ScanRepository) FindAll(page, pageSize int, targetID int, status, search string) ([]model.Scan, int64, error) {
var scans []model.Scan
var total int64
// Build base query
baseQuery := r.db.Model(&model.Scan{}).Where("scan.deleted_at IS NULL")
// Apply target filter
if targetID > 0 {
baseQuery = baseQuery.Where("scan.target_id = ?", targetID)
}
// Apply status filter
if status != "" {
baseQuery = baseQuery.Where("scan.status = ?", status)
}
// Apply search filter (search by target name via join)
if search != "" {
baseQuery = baseQuery.Joins("LEFT JOIN target ON target.id = scan.target_id").
Where("target.name ILIKE ?", "%"+search+"%")
}
// Count total
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
// Fetch with preload and pagination
err := baseQuery.
Preload("Target").
Scopes(
scope.WithPagination(page, pageSize),
scope.OrderByCreatedAtDesc(),
).
Find(&scans).Error
return scans, total, err
}
// Update updates a scan
func (r *ScanRepository) Update(scan *model.Scan) error {
return r.db.Save(scan).Error
}
// SoftDelete soft deletes a scan
func (r *ScanRepository) SoftDelete(id int) error {
now := time.Now()
return r.db.Model(&model.Scan{}).Where("id = ?", id).Update("deleted_at", now).Error
}
// BulkSoftDelete soft deletes multiple scans by IDs
func (r *ScanRepository) BulkSoftDelete(ids []int) (int64, []string, error) {
if len(ids) == 0 {
return 0, nil, nil
}
// Get scan names before deleting
var scans []model.Scan
if err := r.db.Select("id, target_id").
Where("id IN ? AND deleted_at IS NULL", ids).
Preload("Target", "deleted_at IS NULL").
Find(&scans).Error; err != nil {
return 0, nil, err
}
names := make([]string, 0, len(scans))
for _, s := range scans {
if s.Target != nil {
names = append(names, s.Target.Name)
}
}
// Soft delete
now := time.Now()
result := r.db.Model(&model.Scan{}).
Where("id IN ? AND deleted_at IS NULL", ids).
Update("deleted_at", now)
return result.RowsAffected, names, result.Error
}
// UpdateStatus updates scan status
func (r *ScanRepository) UpdateStatus(id int, status string, errorMessage ...string) error {
updates := map[string]interface{}{"status": status}
if len(errorMessage) > 0 {
updates["error_message"] = errorMessage[0]
}
if status == model.ScanStatusCompleted || status == model.ScanStatusFailed || status == model.ScanStatusStopped {
now := time.Now()
updates["stopped_at"] = &now
}
return r.db.Model(&model.Scan{}).Where("id = ?", id).Updates(updates).Error
}
// UpdateProgress updates scan progress
func (r *ScanRepository) UpdateProgress(id int, progress int, currentStage string) error {
return r.db.Model(&model.Scan{}).Where("id = ?", id).
Updates(map[string]interface{}{
"progress": progress,
"current_stage": currentStage,
}).Error
}
// GetStatistics returns scan statistics
func (r *ScanRepository) GetStatistics() (*ScanStatistics, error) {
stats := &ScanStatistics{}
// Count total (excluding soft deleted)
if err := r.db.Model(&model.Scan{}).Where("deleted_at IS NULL").
Count(&stats.Total).Error; err != nil {
return nil, err
}
// Count by status
if err := r.db.Model(&model.Scan{}).Where("deleted_at IS NULL AND status = ?", model.ScanStatusRunning).
Count(&stats.Running).Error; err != nil {
return nil, err
}
if err := r.db.Model(&model.Scan{}).Where("deleted_at IS NULL AND status = ?", model.ScanStatusCompleted).
Count(&stats.Completed).Error; err != nil {
return nil, err
}
if err := r.db.Model(&model.Scan{}).Where("deleted_at IS NULL AND status = ?", model.ScanStatusFailed).
Count(&stats.Failed).Error; err != nil {
return nil, err
}
// Sum cached counts from all scans
type sumResult struct {
TotalVulns int64
TotalSubdomains int64
TotalEndpoints int64
TotalWebsites int64
}
var sums sumResult
if err := r.db.Model(&model.Scan{}).Where("deleted_at IS NULL").
Select(`
COALESCE(SUM(cached_vulns_total), 0) as total_vulns,
COALESCE(SUM(cached_subdomains_count), 0) as total_subdomains,
COALESCE(SUM(cached_endpoints_count), 0) as total_endpoints,
COALESCE(SUM(cached_websites_count), 0) as total_websites
`).
Scan(&sums).Error; err != nil {
return nil, err
}
stats.TotalVulns = sums.TotalVulns
stats.TotalSubdomains = sums.TotalSubdomains
stats.TotalEndpoints = sums.TotalEndpoints
stats.TotalWebsites = sums.TotalWebsites
stats.TotalAssets = sums.TotalSubdomains + sums.TotalEndpoints + sums.TotalWebsites
return stats, nil
}
// ScanStatistics holds scan statistics
type ScanStatistics struct {
Total int64
Running int64
Completed int64
Failed int64
TotalVulns int64
TotalSubdomains int64
TotalEndpoints int64
TotalWebsites int64
TotalAssets int64
}
// FindByTargetIDs finds scans by target IDs
func (r *ScanRepository) FindByTargetIDs(targetIDs []int) ([]model.Scan, error) {
if len(targetIDs) == 0 {
return nil, nil
}
var scans []model.Scan
err := r.db.Where("target_id IN ? AND deleted_at IS NULL", targetIDs).
Preload("Target").
Order("created_at DESC").
Find(&scans).Error
return scans, err
}
// HasActiveScan checks if target has an active scan
func (r *ScanRepository) HasActiveScan(targetID int) (bool, error) {
var count int64
err := r.db.Model(&model.Scan{}).
Where("target_id = ? AND deleted_at IS NULL AND status IN ?", targetID,
[]string{model.ScanStatusInitiated, model.ScanStatusRunning, model.ScanStatusPending}).
Count(&count).Error
return count > 0, err
}

View File

@@ -0,0 +1,86 @@
package repository
import (
"github.com/xingrin/go-backend/internal/model"
"github.com/xingrin/go-backend/internal/pkg/scope"
"gorm.io/gorm"
)
// ScanLogRepository handles scan log database operations
type ScanLogRepository struct {
db *gorm.DB
}
// NewScanLogRepository creates a new scan log repository
func NewScanLogRepository(db *gorm.DB) *ScanLogRepository {
return &ScanLogRepository{db: db}
}
// Create creates a new scan log
func (r *ScanLogRepository) Create(log *model.ScanLog) error {
return r.db.Create(log).Error
}
// BulkCreate creates multiple scan logs
func (r *ScanLogRepository) BulkCreate(logs []model.ScanLog) error {
if len(logs) == 0 {
return nil
}
return r.db.CreateInBatches(logs, 100).Error
}
// FindByScanID finds logs by scan ID with pagination
func (r *ScanLogRepository) FindByScanID(scanID int, page, pageSize int, level string) ([]model.ScanLog, int64, error) {
var logs []model.ScanLog
var total int64
// Build base query
baseQuery := r.db.Model(&model.ScanLog{}).Where("scan_id = ?", scanID)
// Apply level filter
if level != "" {
baseQuery = baseQuery.Where("level = ?", level)
}
// Count total
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
// Fetch with pagination (ordered by created_at ASC for logs)
err := baseQuery.
Scopes(scope.WithPagination(page, pageSize)).
Order("created_at ASC").
Find(&logs).Error
return logs, total, err
}
// FindByScanIDWithCursor finds logs by scan ID with cursor pagination
func (r *ScanLogRepository) FindByScanIDWithCursor(scanID int, afterID int64, limit int) ([]model.ScanLog, error) {
var logs []model.ScanLog
query := r.db.Where("scan_id = ?", scanID)
// Apply cursor filter
if afterID > 0 {
query = query.Where("id > ?", afterID)
}
// Order by ID (auto-increment, guarantees consistent order)
err := query.Order("id ASC").Limit(limit).Find(&logs).Error
return logs, err
}
// DeleteByScanID deletes all logs for a scan
func (r *ScanLogRepository) DeleteByScanID(scanID int) error {
return r.db.Where("scan_id = ?", scanID).Delete(&model.ScanLog{}).Error
}
// DeleteByScanIDs deletes all logs for multiple scans
func (r *ScanLogRepository) DeleteByScanIDs(scanIDs []int) error {
if len(scanIDs) == 0 {
return nil
}
return r.db.Where("scan_id IN ?", scanIDs).Delete(&model.ScanLog{}).Error
}

View File

@@ -108,9 +108,9 @@ func (r *VulnerabilityRepository) FindByTargetID(targetID, page, pageSize int, f
return vulnerabilities, total, err
}
// BulkUpsert creates multiple vulnerabilities (simple bulk create, no upsert)
// BulkCreate creates multiple vulnerabilities
// Note: Vulnerability model has no unique constraint, same as Python backend
func (r *VulnerabilityRepository) BulkUpsert(vulnerabilities []model.Vulnerability) (int64, error) {
func (r *VulnerabilityRepository) BulkCreate(vulnerabilities []model.Vulnerability) (int64, error) {
if len(vulnerabilities) == 0 {
return 0, nil
}

View File

@@ -0,0 +1,206 @@
package service
import (
"encoding/json"
"errors"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/model"
"github.com/xingrin/go-backend/internal/repository"
"gorm.io/gorm"
)
var (
ErrScanNotFound = errors.New("scan not found")
ErrScanCannotStop = errors.New("scan cannot be stopped in current status")
ErrNoTargetsForScan = errors.New("no targets provided for scan")
ErrTargetHasActiveScan = errors.New("target already has an active scan")
)
// ScanService handles scan business logic
type ScanService struct {
repo *repository.ScanRepository
targetRepo *repository.TargetRepository
orgRepo *repository.OrganizationRepository
}
// NewScanService creates a new scan service
func NewScanService(
repo *repository.ScanRepository,
scanLogRepo *repository.ScanLogRepository, // Keep for backward compatibility, but not used
targetRepo *repository.TargetRepository,
orgRepo *repository.OrganizationRepository,
) *ScanService {
return &ScanService{
repo: repo,
targetRepo: targetRepo,
orgRepo: orgRepo,
}
}
// List returns paginated scans
func (s *ScanService) List(query *dto.ScanListQuery) ([]model.Scan, int64, error) {
return s.repo.FindAll(query.GetPage(), query.GetPageSize(), query.TargetID, query.Status, query.Search)
}
// GetByID returns a scan by ID
func (s *ScanService) GetByID(id int) (*model.Scan, error) {
scan, err := s.repo.FindByIDWithTarget(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrScanNotFound
}
return nil, err
}
return scan, nil
}
// Delete soft deletes a scan (two-phase delete)
func (s *ScanService) Delete(id int) (int64, []string, error) {
// Check if scan exists
_, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil, ErrScanNotFound
}
return 0, nil, err
}
// Soft delete
return s.repo.BulkSoftDelete([]int{id})
}
// BulkDelete soft deletes multiple scans
func (s *ScanService) BulkDelete(ids []int) (int64, []string, error) {
if len(ids) == 0 {
return 0, nil, nil
}
return s.repo.BulkSoftDelete(ids)
}
// GetStatistics returns scan statistics
func (s *ScanService) GetStatistics() (*dto.ScanStatisticsResponse, error) {
stats, err := s.repo.GetStatistics()
if err != nil {
return nil, err
}
return &dto.ScanStatisticsResponse{
Total: stats.Total,
Running: stats.Running,
Completed: stats.Completed,
Failed: stats.Failed,
TotalVulns: stats.TotalVulns,
TotalSubdomains: stats.TotalSubdomains,
TotalEndpoints: stats.TotalEndpoints,
TotalWebsites: stats.TotalWebsites,
TotalAssets: stats.TotalAssets,
}, nil
}
// Stop stops a running scan
func (s *ScanService) Stop(id int) (int, error) {
scan, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrScanNotFound
}
return 0, err
}
// Check if scan can be stopped
if scan.Status != model.ScanStatusRunning && scan.Status != model.ScanStatusInitiated {
return 0, ErrScanCannotStop
}
// Update status to stopped
if err := s.repo.UpdateStatus(id, model.ScanStatusStopped); err != nil {
return 0, err
}
// TODO: Revoke celery tasks when worker integration is implemented
// For now, just return 0 revoked tasks
return 0, nil
}
// ToScanResponse converts scan model to response DTO
func (s *ScanService) ToScanResponse(scan *model.Scan) *dto.ScanResponse {
resp := &dto.ScanResponse{
ID: scan.ID,
TargetID: scan.TargetID,
ScanMode: scan.ScanMode,
Status: scan.Status,
Progress: scan.Progress,
CurrentStage: scan.CurrentStage,
ErrorMessage: scan.ErrorMessage,
CreatedAt: scan.CreatedAt,
StoppedAt: scan.StoppedAt,
}
// Convert engine IDs
if scan.EngineIDs != nil {
resp.EngineIDs = scan.EngineIDs
} else {
resp.EngineIDs = []int64{}
}
// Convert engine names from JSON
if scan.EngineNames != nil {
var names []string
if err := json.Unmarshal(scan.EngineNames, &names); err == nil {
resp.EngineNames = names
} else {
resp.EngineNames = []string{}
}
} else {
resp.EngineNames = []string{}
}
// Add target info
if scan.Target != nil {
resp.Target = &dto.TargetBrief{
ID: scan.Target.ID,
Name: scan.Target.Name,
Type: scan.Target.Type,
}
}
// Add cached stats
resp.CachedStats = &dto.ScanCachedStats{
SubdomainsCount: scan.CachedSubdomainsCount,
WebsitesCount: scan.CachedWebsitesCount,
EndpointsCount: scan.CachedEndpointsCount,
IPsCount: scan.CachedIPsCount,
DirectoriesCount: scan.CachedDirectoriesCount,
ScreenshotsCount: scan.CachedScreenshotsCount,
VulnsTotal: scan.CachedVulnsTotal,
VulnsCritical: scan.CachedVulnsCritical,
VulnsHigh: scan.CachedVulnsHigh,
VulnsMedium: scan.CachedVulnsMedium,
VulnsLow: scan.CachedVulnsLow,
}
return resp
}
// ToScanDetailResponse converts scan model to detailed response DTO
func (s *ScanService) ToScanDetailResponse(scan *model.Scan) *dto.ScanDetailResponse {
base := s.ToScanResponse(scan)
resp := &dto.ScanDetailResponse{
ScanResponse: *base,
YamlConfiguration: scan.YamlConfiguration,
ResultsDir: scan.ResultsDir,
WorkerID: scan.WorkerID,
}
// Convert stage progress from JSON
if scan.StageProgress != nil {
var progress map[string]interface{}
if err := json.Unmarshal(scan.StageProgress, &progress); err == nil {
resp.StageProgress = progress
}
}
return resp
}

View File

@@ -0,0 +1,106 @@
package service
import (
"errors"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/model"
"github.com/xingrin/go-backend/internal/repository"
"gorm.io/gorm"
)
// ScanLogService handles scan log business logic
type ScanLogService struct {
repo *repository.ScanLogRepository
scanRepo *repository.ScanRepository
}
// NewScanLogService creates a new scan log service
func NewScanLogService(repo *repository.ScanLogRepository, scanRepo *repository.ScanRepository) *ScanLogService {
return &ScanLogService{
repo: repo,
scanRepo: scanRepo,
}
}
// ListByScanID returns logs for a scan with cursor pagination
func (s *ScanLogService) ListByScanID(scanID int, query *dto.ScanLogListQuery) (*dto.ScanLogListResponse, error) {
// Check if scan exists
_, err := s.scanRepo.FindByID(scanID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrScanNotFound
}
return nil, err
}
// Get limit (default 200, max 1000)
limit := query.Limit
if limit <= 0 {
limit = 200
}
if limit > 1000 {
limit = 1000
}
// Query logs with cursor pagination
logs, err := s.repo.FindByScanIDWithCursor(scanID, query.AfterID, limit+1)
if err != nil {
return nil, err
}
// Check if there are more logs
hasMore := len(logs) > limit
if hasMore {
logs = logs[:limit]
}
// Convert to response DTOs
results := make([]dto.ScanLogResponse, len(logs))
for i, log := range logs {
results[i] = dto.ScanLogResponse{
ID: log.ID,
ScanID: log.ScanID,
Level: log.Level,
Content: log.Content,
CreatedAt: log.CreatedAt,
}
}
return &dto.ScanLogListResponse{
Results: results,
HasMore: hasMore,
}, nil
}
// BulkCreate creates multiple logs for a scan
func (s *ScanLogService) BulkCreate(scanID int, items []dto.ScanLogItem) (int, error) {
// Check if scan exists
_, err := s.scanRepo.FindByID(scanID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrScanNotFound
}
return 0, err
}
if len(items) == 0 {
return 0, nil
}
// Convert to models
logs := make([]model.ScanLog, len(items))
for i, item := range items {
logs[i] = model.ScanLog{
ScanID: scanID,
Level: item.Level,
Content: item.Content,
}
}
if err := s.repo.BulkCreate(logs); err != nil {
return 0, err
}
return len(logs), nil
}

View File

@@ -60,8 +60,8 @@ func (s *VulnerabilityService) ListByTarget(targetID int, query *dto.Vulnerabili
return s.repo.FindByTargetID(targetID, query.GetPage(), query.GetPageSize(), query.Filter, query.Severity, query.IsReviewed)
}
// BulkUpsert creates or updates multiple vulnerabilities for a target
func (s *VulnerabilityService) BulkUpsert(targetID int, items []dto.VulnerabilityUpsertItem) (int64, error) {
// BulkCreate creates multiple vulnerabilities for a target
func (s *VulnerabilityService) BulkCreate(targetID int, items []dto.VulnerabilityCreateItem) (int64, error) {
// Verify target exists
_, err := s.targetRepo.FindByID(targetID)
if err != nil {
@@ -100,7 +100,7 @@ func (s *VulnerabilityService) BulkUpsert(targetID int, items []dto.Vulnerabilit
return 0, nil
}
return s.repo.BulkUpsert(vulnerabilities)
return s.repo.BulkCreate(vulnerabilities)
}
// BulkDelete deletes multiple vulnerabilities by IDs