mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 11:46:16 +08:00
完成漏洞的review,scan的基本curd
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "待审查"
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
145
go-backend/internal/dto/scan.go
Normal file
145
go-backend/internal/dto/scan.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
166
go-backend/internal/handler/scan.go
Normal file
166
go-backend/internal/handler/scan.go
Normal 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")
|
||||
}
|
||||
76
go-backend/internal/handler/scan_log.go
Normal file
76
go-backend/internal/handler/scan_log.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
248
go-backend/internal/repository/scan.go
Normal file
248
go-backend/internal/repository/scan.go
Normal 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
|
||||
}
|
||||
86
go-backend/internal/repository/scan_log.go
Normal file
86
go-backend/internal/repository/scan_log.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
206
go-backend/internal/service/scan.go
Normal file
206
go-backend/internal/service/scan.go
Normal 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
|
||||
}
|
||||
106
go-backend/internal/service/scan_log.go
Normal file
106
go-backend/internal/service/scan_log.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user