From e3003f33f9c6e016788785f4fc15f825d594d446 Mon Sep 17 00:00:00 2001 From: yyhuni Date: Wed, 14 Jan 2026 08:21:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=BC=8F=E6=B4=9E=E7=9A=84re?= =?UTF-8?q?view=EF=BC=8Cscan=E7=9A=84=E5=9F=BA=E6=9C=ACcurd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scan/history/scan-history-columns.tsx | 40 ++- .../scan/history/scan-history-data-table.tsx | 47 +++- .../scan/history/scan-history-list.tsx | 23 +- .../components/scan/history/scan-overview.tsx | 23 +- .../components/scan/scan-progress-dialog.tsx | 28 +- .../vulnerabilities-columns.tsx | 42 ++- .../vulnerabilities-data-table.tsx | 229 +++++++++++----- .../vulnerabilities-detail-view.tsx | 2 + frontend/messages/en.json | 8 +- frontend/messages/zh.json | 8 +- frontend/mock/data/scans.ts | 242 ++++++++--------- frontend/types/scan.types.ts | 61 +++-- go-backend/cmd/seed/main.go | 132 ++++++++++ go-backend/cmd/server/main.go | 22 +- go-backend/internal/dto/scan.go | 145 ++++++++++ go-backend/internal/dto/vulnerability.go | 16 +- go-backend/internal/handler/scan.go | 166 ++++++++++++ go-backend/internal/handler/scan_log.go | 76 ++++++ go-backend/internal/handler/vulnerability.go | 16 +- go-backend/internal/repository/scan.go | 248 ++++++++++++++++++ go-backend/internal/repository/scan_log.go | 86 ++++++ .../internal/repository/vulnerability.go | 4 +- go-backend/internal/service/scan.go | 206 +++++++++++++++ go-backend/internal/service/scan_log.go | 106 ++++++++ go-backend/internal/service/vulnerability.go | 6 +- 25 files changed, 1672 insertions(+), 310 deletions(-) create mode 100644 go-backend/internal/dto/scan.go create mode 100644 go-backend/internal/handler/scan.go create mode 100644 go-backend/internal/handler/scan_log.go create mode 100644 go-backend/internal/repository/scan.go create mode 100644 go-backend/internal/repository/scan_log.go create mode 100644 go-backend/internal/service/scan.go create mode 100644 go-backend/internal/service/scan_log.go diff --git a/frontend/components/scan/history/scan-history-columns.tsx b/frontend/components/scan/history/scan-history-columns.tsx index 0bc12d4a..f3359af2 100644 --- a/frontend/components/scan/history/scan-history-columns.tsx +++ b/frontend/components/scan/history/scan-history-columns.tsx @@ -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 = ({ ), 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 (
@@ -239,7 +240,8 @@ export const createScanHistoryColumns = ({ }, }, { - accessorKey: "summary", + accessorKey: "cachedStats", + accessorFn: (row) => row.cachedStats, meta: { title: t.columns.summary }, header: ({ column }) => ( @@ -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 = ({

- {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}

@@ -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 diff --git a/frontend/components/scan/history/scan-history-data-table.tsx b/frontend/components/scan/history/scan-history-data-table.tsx index 550153e0..5d558f19 100644 --- a/frontend/components/scan/history/scan-history-data-table.tsx +++ b/frontend/components/scan/history/scan-history-data-table.tsx @@ -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 ( +
)} + {onStatusFilterChange && ( + + )}
} /> diff --git a/frontend/components/scan/history/scan-history-list.tsx b/frontend/components/scan/history/scan-history-list.tsx index c91d006a..19e0fe83 100644 --- a/frontend/components/scan/history/scan-history-list.tsx +++ b/frontend/components/scan/history/scan-history-list.tsx @@ -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("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 {tConfirm("deleteTitle")} - {tConfirm("deleteScanMessage", { name: scanToDelete?.targetName ?? "" })} + {tConfirm("deleteScanMessage", { name: scanToDelete?.target?.name ?? "" })} @@ -376,7 +387,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
    {selectedScans.map((scan) => (
  • - {scan.targetName} + {scan.target?.name} {scan.engineNames?.join(", ") || "-"}
  • ))} @@ -400,7 +411,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo {tConfirm("stopScanTitle")} - {tConfirm("stopScanMessage", { name: scanToStop?.targetName ?? "" })} + {tConfirm("stopScanMessage", { name: scanToStop?.target?.name ?? "" })} diff --git a/frontend/components/scan/history/scan-overview.tsx b/frontend/components/scan/history/scan-overview.tsx index ebbbb45e..84fcb970 100644 --- a/frontend/components/scan/history/scan-overview.tsx +++ b/frontend/components/scan/history/scan-overview.tsx @@ -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 diff --git a/frontend/components/scan/scan-progress-dialog.tsx b/frontend/components/scan/scan-progress-dialog.tsx index 38348bef..ae55833f 100644 --- a/frontend/components/scan/scan-progress-dialog.tsx +++ b/frontend/components/scan/scan-progress-dialog.tsx @@ -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({
    {t("target")} - {data.targetName} + {data.target?.name}
    {t("engine")} @@ -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, diff --git a/frontend/components/vulnerabilities/vulnerabilities-columns.tsx b/frontend/components/vulnerabilities/vulnerabilities-columns.tsx index 30208e9a..937aea9d 100644 --- a/frontend/components/vulnerabilities/vulnerabilities-columns.tsx +++ b/frontend/components/vulnerabilities/vulnerabilities-columns.tsx @@ -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 ( - - - - - - {isReviewed ? t.tooltips.reviewed : t.tooltips.pending} - - + onToggleReview?.(row.original)} + > + {isReviewed ? t.tooltips.reviewed : t.tooltips.pending} + ) }, enableSorting: false, diff --git a/frontend/components/vulnerabilities/vulnerabilities-data-table.tsx b/frontend/components/vulnerabilities/vulnerabilities-data-table.tsx index c8bb1fdb..899fe9e2 100644 --- a/frontend/components/vulnerabilities/vulnerabilities-data-table.tsx +++ b/frontend/components/vulnerabilities/vulnerabilities-data-table.tsx @@ -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[] @@ -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 = ( -
    + // 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 && ( + + + + + + {severityOptions.map((sev) => ( + onSeverityFilterChange(sev)} + > + {sev === "all" ? tVuln("reviewStatus.all") : tSeverity(sev)} + + ))} + + + )} + + {/* Source dropdown filter */} + {onSourceFilterChange && availableSources.length > 0 && ( + + + + + + onSourceFilterChange("all")} + > + {tVuln("reviewStatus.all")} + + {availableSources.map((src) => ( + onSourceFilterChange(src)} + > + {src} + + ))} + + + )} + {/* Review filter tabs */} {onReviewFilterChange && ( onReviewFilterChange(v as ReviewFilter)}> - - + + {tVuln("reviewStatus.all")} - + {tVuln("reviewStatus.pending")} {pendingCount > 0 && ( - + {pendingCount} )} - + {tVuln("reviewStatus.reviewed")} )} + + ) - {/* Bulk review actions */} - {selectedRows.length > 0 && ( -
    - {onBulkMarkAsReviewed && ( - - )} - {onBulkMarkAsPending && ( - - )} -
    - )} + // Floating action bar for bulk operations + const floatingActionBar = selectedRows.length > 0 && (onBulkMarkAsReviewed || onBulkMarkAsPending) && ( +
    +
    + + {tVuln("selected", { count: selectedRows.length })} + +
    + {onBulkMarkAsReviewed && ( + + )} + {onBulkMarkAsPending && ( + + )} + {onSelectionChange && ( + + )} +
    ) return ( - 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")} - /> + <> + 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} + ) } diff --git a/frontend/components/vulnerabilities/vulnerabilities-detail-view.tsx b/frontend/components/vulnerabilities/vulnerabilities-detail-view.tsx index c2588ba1..9d954299 100644 --- a/frontend/components/vulnerabilities/vulnerabilities-detail-view.tsx +++ b/frontend/components/vulnerabilities/vulnerabilities-detail-view.tsx @@ -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", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 355fea8f..b8eff498 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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" diff --git a/frontend/messages/zh.json b/frontend/messages/zh.json index 862dc430..bf45c308 100644 --- a/frontend/messages/zh.json +++ b/frontend/messages/zh.json @@ -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": "待审查" diff --git a/frontend/mock/data/scans.ts b/frontend/mock/data/scans.ts index 4de4c817..a4aa572e 100644 --- a/frontend/mock/data/scans.ts +++ b/frontend/mock/data/scans.ts @@ -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) ) } diff --git a/frontend/types/scan.types.ts b/frontend/types/scan.types.ts index 8cb01be9..d5facf3d 100644 --- a/frontend/types/scan.types.ts +++ b/frontend/types/scan.types.ts @@ -32,34 +32,51 @@ export interface StageProgressItem { */ export type StageProgress = Record +/** + * 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 { diff --git a/go-backend/cmd/seed/main.go b/go-backend/cmd/seed/main.go index be104476..d0db1dc6 100644 --- a/go-backend/cmd/seed/main.go +++ b/go-backend/cmd/seed/main.go @@ -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 +} diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go index 4cc727d0..b9a9ec52 100644 --- a/go-backend/cmd/server/main.go +++ b/go-backend/cmd/server/main.go @@ -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) } } diff --git a/go-backend/internal/dto/scan.go b/go-backend/internal/dto/scan.go new file mode 100644 index 00000000..683675d0 --- /dev/null +++ b/go-backend/internal/dto/scan.go @@ -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"` +} diff --git a/go-backend/internal/dto/vulnerability.go b/go-backend/internal/dto/vulnerability.go index 2ff4295b..def99a19 100644 --- a/go-backend/internal/dto/vulnerability.go +++ b/go-backend/internal/dto/vulnerability.go @@ -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 diff --git a/go-backend/internal/handler/scan.go b/go-backend/internal/handler/scan.go new file mode 100644 index 00000000..c1a8736f --- /dev/null +++ b/go-backend/internal/handler/scan.go @@ -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") +} diff --git a/go-backend/internal/handler/scan_log.go b/go-backend/internal/handler/scan_log.go new file mode 100644 index 00000000..fd78064c --- /dev/null +++ b/go-backend/internal/handler/scan_log.go @@ -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, + }) +} diff --git a/go-backend/internal/handler/vulnerability.go b/go-backend/internal/handler/vulnerability.go index 465e0b89..5fd857a0 100644 --- a/go-backend/internal/handler/vulnerability.go +++ b/go-backend/internal/handler/vulnerability.go @@ -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), }) } diff --git a/go-backend/internal/repository/scan.go b/go-backend/internal/repository/scan.go new file mode 100644 index 00000000..aae5eb25 --- /dev/null +++ b/go-backend/internal/repository/scan.go @@ -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 +} diff --git a/go-backend/internal/repository/scan_log.go b/go-backend/internal/repository/scan_log.go new file mode 100644 index 00000000..59f85df4 --- /dev/null +++ b/go-backend/internal/repository/scan_log.go @@ -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 +} diff --git a/go-backend/internal/repository/vulnerability.go b/go-backend/internal/repository/vulnerability.go index 936d2d77..09750105 100644 --- a/go-backend/internal/repository/vulnerability.go +++ b/go-backend/internal/repository/vulnerability.go @@ -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 } diff --git a/go-backend/internal/service/scan.go b/go-backend/internal/service/scan.go new file mode 100644 index 00000000..2114ad6b --- /dev/null +++ b/go-backend/internal/service/scan.go @@ -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 +} diff --git a/go-backend/internal/service/scan_log.go b/go-backend/internal/service/scan_log.go new file mode 100644 index 00000000..59a948cc --- /dev/null +++ b/go-backend/internal/service/scan_log.go @@ -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 +} diff --git a/go-backend/internal/service/vulnerability.go b/go-backend/internal/service/vulnerability.go index 9d049a61..0142aafc 100644 --- a/go-backend/internal/service/vulnerability.go +++ b/go-backend/internal/service/vulnerability.go @@ -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