feat(search): add mock data infrastructure and vulnerability detail integration

- Add comprehensive mock data configuration for all major entities (dashboard, endpoints, organizations, scans, subdomains, targets, vulnerabilities, websites)
- Implement mock service layer with centralized config for development and testing
- Add vulnerability detail dialog integration to search results with lazy loading
- Enhance search result card with vulnerability viewing capability
- Update search materialized view migration to include vulnerability name field
- Implement default host fuzzy search fallback for bare text queries without operators
- Add vulnerability data formatting in search view for consistent API response structure
- Configure Vercel deployment settings and update Next.js configuration
- Update all service layers to support mock data injection for development environment
- Extend search types with improved vulnerability data structure
- Add internationalization strings for vulnerability loading errors
- Enable rapid frontend development and testing without backend API dependency
This commit is contained in:
yyhuni
2026-01-02 19:06:09 +08:00
parent 18cc016268
commit db8ecb1644
28 changed files with 1873 additions and 57 deletions

View File

@@ -57,6 +57,7 @@ class Migration(migrations.Migration):
json_agg(
json_build_object(
'id', v.id,
'name', v.vuln_type,
'vuln_type', v.vuln_type,
'severity', v.severity,
'url', v.url

View File

@@ -68,6 +68,11 @@ class SearchQueryParser:
query = query.strip()
# 检查是否包含操作符语法,如果不包含则作为 host 模糊搜索
if not cls.CONDITION_PATTERN.search(query):
# 裸文本,默认作为 host 模糊搜索
return "host ILIKE %s", [f"%{query}%"]
# 按 || 分割为 OR 组
or_groups = cls._split_by_or(query)

View File

@@ -125,6 +125,17 @@ class AssetSearchView(APIView):
except (json.JSONDecodeError, TypeError):
vulnerabilities = []
# 格式化漏洞数据
formatted_vulns = []
for v in (vulnerabilities or []):
formatted_vulns.append({
'id': v.get('id'),
'name': v.get('name', v.get('vuln_type', '')),
'vulnType': v.get('vuln_type', ''),
'severity': v.get('severity', 'info'),
'url': v.get('url', ''),
})
formatted_results.append({
'url': result.get('url', ''),
'host': result.get('host', ''),
@@ -133,7 +144,7 @@ class AssetSearchView(APIView):
'statusCode': result.get('status_code'),
'responseHeaders': response_headers,
'responseBody': result.get('response_body', ''),
'vulnerabilities': vulnerabilities or [],
'vulnerabilities': formatted_vulns,
})
return success_response(data={

View File

@@ -4,11 +4,15 @@ import { useState, useCallback } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Search, AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { SmartFilterInput, type FilterField } from "@/components/common/smart-filter-input"
import { SearchResultCard } from "./search-result-card"
import { SearchPagination } from "./search-pagination"
import { useAssetSearch } from "@/hooks/use-search"
import type { SearchParams, SearchState } from "@/types/search.types"
import { VulnerabilityDetailDialog } from "@/components/vulnerabilities/vulnerability-detail-dialog"
import { VulnerabilityService } from "@/services/vulnerability.service"
import type { SearchParams, SearchState, Vulnerability as SearchVuln } from "@/types/search.types"
import type { Vulnerability } from "@/types/vulnerability.types"
import { Alert, AlertDescription } from "@/components/ui/alert"
// 搜索示例 - 展示各种查询语法
@@ -37,29 +41,6 @@ const SEARCH_FILTER_EXAMPLES = [
'host="example" && status!="404" && tech="nginx"',
]
// 验证搜索查询语法
function validateSearchQuery(query: string): { valid: boolean; error?: string } {
if (!query.trim()) {
return { valid: false, error: 'Query cannot be empty' }
}
// 检查是否有未闭合的引号
const quoteCount = (query.match(/"/g) || []).length
if (quoteCount % 2 !== 0) {
return { valid: false, error: 'Unclosed quote detected' }
}
// 检查基本语法field="value" 或 field=="value" 或 field!="value"
const conditionPattern = /(\w+)\s*(==|!=|=)\s*"([^"]*)"/g
const conditions = query.match(conditionPattern)
if (!conditions || conditions.length === 0) {
return { valid: false, error: 'Invalid syntax. Use: field="value", field=="value", or field!="value"' }
}
return { valid: true }
}
export function SearchPage() {
const t = useTranslations('search')
const [searchState, setSearchState] = useState<SearchState>("initial")
@@ -67,6 +48,9 @@ export function SearchPage() {
const [searchParams, setSearchParams] = useState<SearchParams>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [selectedVuln, setSelectedVuln] = useState<Vulnerability | null>(null)
const [vulnDialogOpen, setVulnDialogOpen] = useState(false)
const [loadingVuln, setLoadingVuln] = useState(false)
// 搜索过滤字段配置
const SEARCH_FILTER_FIELDS: FilterField[] = [
@@ -88,15 +72,7 @@ export function SearchPage() {
const handleSearch = useCallback((_filters: unknown, rawQuery: string) => {
if (!rawQuery.trim()) return
// 验证语法
const validation = validateSearchQuery(rawQuery)
if (!validation.valid) {
// 可以显示错误提示,这里简单处理
console.warn('Search validation:', validation.error)
}
setQuery(rawQuery)
// 直接将原始查询发送给后端解析
setSearchParams({ q: rawQuery })
setPage(1)
setSearchState("searching")
@@ -116,6 +92,21 @@ export function SearchPage() {
setPage(1)
}, [])
const handleViewVulnerability = useCallback(async (vuln: SearchVuln) => {
if (!vuln.id) return
setLoadingVuln(true)
try {
const fullVuln = await VulnerabilityService.getVulnerabilityById(vuln.id)
setSelectedVuln(fullVuln)
setVulnDialogOpen(true)
} catch {
toast.error(t('vulnLoadError'))
} finally {
setLoadingVuln(false)
}
}, [t])
return (
<div className="flex-1 w-full flex flex-col">
<AnimatePresence mode="wait">
@@ -233,7 +224,10 @@ export function SearchPage() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<SearchResultCard result={result} />
<SearchResultCard
result={result}
onViewVulnerability={handleViewVulnerability}
/>
</motion.div>
))}
</div>
@@ -257,6 +251,13 @@ export function SearchPage() {
</motion.div>
)}
</AnimatePresence>
{/* 漏洞详情弹窗 - 复用现有组件 */}
<VulnerabilityDetailDialog
vulnerability={selectedVuln}
open={vulnDialogOpen}
onOpenChange={setVulnDialogOpen}
/>
</div>
)
}

View File

@@ -190,10 +190,9 @@ export function SearchResultCard({ result, onViewVulnerability }: SearchResultCa
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="text-xs w-[40%]">{t('vulnName')}</TableHead>
<TableHead className="text-xs w-[15%]">{t('severity')}</TableHead>
<TableHead className="text-xs w-[15%]">{t('source')}</TableHead>
<TableHead className="text-xs w-[50%]">{t('vulnName')}</TableHead>
<TableHead className="text-xs w-[20%]">{t('vulnType')}</TableHead>
<TableHead className="text-xs w-[20%]">{t('severity')}</TableHead>
<TableHead className="text-xs w-[10%]"></TableHead>
</TableRow>
</TableHeader>
@@ -212,19 +211,6 @@ export function SearchResultCard({ result, onViewVulnerability }: SearchResultCa
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`text-xs ${severityColors[vuln.severity] || severityColors.info}`}
>
{vuln.severity}
</Badge>
</TableCell>
<TableCell className="text-xs">
<Badge variant="outline" className="text-xs">
{vuln.source}
</Badge>
</TableCell>
<TableCell className="text-xs">
<Tooltip>
<TooltipTrigger asChild>
@@ -237,6 +223,14 @@ export function SearchResultCard({ result, onViewVulnerability }: SearchResultCa
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`text-xs ${severityColors[vuln.severity] || severityColors.info}`}
>
{vuln.severity}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"

View File

@@ -317,13 +317,14 @@
},
"search": {
"title": "Asset Search",
"hint": "Click search box to view available fields and syntax",
"hint": "Click search box to view available fields and syntax. Plain text defaults to hostname search",
"searching": "Searching...",
"loading": "Loading...",
"resultsCount": "Found {count} results",
"error": "Search failed, please try again later",
"noResults": "No matching assets found",
"noResultsHint": "Try adjusting your search criteria",
"vulnLoadError": "Failed to load vulnerability details",
"fields": {
"host": "Hostname",
"url": "URL address",
@@ -342,6 +343,13 @@
"severity": "Severity",
"source": "Source",
"vulnType": "Vuln Type"
},
"vulnDetail": {
"title": "Vulnerability Details",
"name": "Vulnerability Name",
"source": "Source",
"type": "Vulnerability Type",
"url": "Vulnerability URL"
}
},
"dashboard": {
@@ -745,6 +753,7 @@
"organizationMode": "Organization Scan",
"organizationModeHint": "In organization scan mode, all targets under this organization will be dynamically fetched at execution",
"noAvailableTarget": "No available targets",
"noEngine": "No engines available",
"selected": "Selected",
"selectedEngines": "{count} engines selected"
},

View File

@@ -317,13 +317,14 @@
},
"search": {
"title": "资产搜索",
"hint": "点击搜索框查看可用字段和语法",
"hint": "点击搜索框查看可用字段和语法,直接输入文本默认搜索主机名",
"searching": "搜索中...",
"loading": "加载中...",
"resultsCount": "找到 {count} 条结果",
"error": "搜索失败,请稍后重试",
"noResults": "未找到匹配的资产",
"noResultsHint": "请尝试调整搜索条件",
"vulnLoadError": "加载漏洞详情失败",
"fields": {
"host": "主机名",
"url": "URL 地址",
@@ -342,6 +343,13 @@
"severity": "严重程度",
"source": "来源",
"vulnType": "漏洞类型"
},
"vulnDetail": {
"title": "漏洞详情",
"name": "漏洞名称",
"source": "来源",
"type": "漏洞类型",
"url": "漏洞 URL"
}
},
"dashboard": {
@@ -745,6 +753,7 @@
"organizationMode": "组织扫描",
"organizationModeHint": "组织扫描模式下,执行时将动态获取该组织下所有目标",
"noAvailableTarget": "暂无可用目标",
"noEngine": "暂无可用引擎",
"selected": "已选择",
"selectedEngines": "已选择 {count} 个引擎"
},

23
frontend/mock/config.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Mock 数据配置
*
* 使用方式:
* 1. 在 .env.local 中设置 NEXT_PUBLIC_USE_MOCK=true 启用 mock 数据
* 2. 或者直接修改下面的 FORCE_MOCK 为 true
*/
// 强制使用 mock 数据(一般保持 false通过环境变量控制
const FORCE_MOCK = false
// 从环境变量读取 mock 配置
export const USE_MOCK = FORCE_MOCK || process.env.NEXT_PUBLIC_USE_MOCK === 'true'
// Mock 数据延迟(模拟网络请求)
export const MOCK_DELAY = 300 // ms
/**
* 模拟网络延迟
*/
export function mockDelay(ms: number = MOCK_DELAY): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

@@ -0,0 +1,71 @@
import type { AssetStatistics, StatisticsHistoryItem, DashboardStats } from '@/types/dashboard.types'
export const mockDashboardStats: DashboardStats = {
totalTargets: 156,
totalSubdomains: 4823,
totalEndpoints: 12456,
totalVulnerabilities: 89,
}
export const mockAssetStatistics: AssetStatistics = {
totalTargets: 156,
totalSubdomains: 4823,
totalIps: 892,
totalEndpoints: 12456,
totalWebsites: 3421,
totalVulns: 89,
totalAssets: 21638,
runningScans: 3,
updatedAt: new Date().toISOString(),
// 变化值
changeTargets: 12,
changeSubdomains: 234,
changeIps: 45,
changeEndpoints: 567,
changeWebsites: 89,
changeVulns: -5,
changeAssets: 942,
// 漏洞严重程度分布
vulnBySeverity: {
critical: 3,
high: 12,
medium: 28,
low: 34,
info: 12,
},
}
// 生成过去 N 天的历史数据
function generateHistoryData(days: number): StatisticsHistoryItem[] {
const data: StatisticsHistoryItem[] = []
const now = new Date()
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
// 模拟逐渐增长的趋势
const factor = 1 + (days - i) * 0.02
data.push({
date: date.toISOString().split('T')[0],
totalTargets: Math.floor(140 * factor),
totalSubdomains: Math.floor(4200 * factor),
totalIps: Math.floor(780 * factor),
totalEndpoints: Math.floor(10800 * factor),
totalWebsites: Math.floor(2980 * factor),
totalVulns: Math.floor(75 * factor),
totalAssets: Math.floor(18900 * factor),
})
}
return data
}
export const mockStatisticsHistory7Days = generateHistoryData(7)
export const mockStatisticsHistory30Days = generateHistoryData(30)
export function getMockStatisticsHistory(days: number): StatisticsHistoryItem[] {
if (days <= 7) return mockStatisticsHistory7Days
return generateHistoryData(days)
}

View File

@@ -0,0 +1,257 @@
import type { Endpoint, GetEndpointsResponse } from '@/types/endpoint.types'
export const mockEndpoints: Endpoint[] = [
{
id: 1,
url: 'https://acme.com/',
method: 'GET',
statusCode: 200,
title: 'Acme Corporation - Home',
contentLength: 45678,
contentType: 'text/html; charset=utf-8',
responseTime: 0.234,
host: 'acme.com',
webserver: 'nginx/1.24.0',
tech: ['React', 'Next.js', 'Node.js'],
createdAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
url: 'https://acme.com/login',
method: 'GET',
statusCode: 200,
title: 'Login - Acme',
contentLength: 12345,
contentType: 'text/html; charset=utf-8',
responseTime: 0.156,
host: 'acme.com',
webserver: 'nginx/1.24.0',
tech: ['React', 'Next.js'],
createdAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
url: 'https://api.acme.com/v1/users',
method: 'GET',
statusCode: 200,
title: '',
contentLength: 8923,
contentType: 'application/json',
responseTime: 0.089,
host: 'api.acme.com',
webserver: 'nginx/1.24.0',
tech: ['Django', 'Python'],
gfPatterns: ['api', 'json'],
createdAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
url: 'https://api.acme.com/v1/products',
method: 'GET',
statusCode: 200,
title: '',
contentLength: 23456,
contentType: 'application/json',
responseTime: 0.145,
host: 'api.acme.com',
webserver: 'nginx/1.24.0',
tech: ['Django', 'Python'],
gfPatterns: ['api', 'json'],
createdAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
url: 'https://acme.io/docs',
method: 'GET',
statusCode: 200,
title: 'Documentation - Acme.io',
contentLength: 67890,
contentType: 'text/html; charset=utf-8',
responseTime: 0.312,
host: 'acme.io',
webserver: 'cloudflare',
tech: ['Vue.js', 'Vitepress'],
createdAt: '2024-12-27T14:30:00Z',
},
{
id: 6,
url: 'https://acme.io/api/config',
method: 'GET',
statusCode: 200,
title: '',
contentLength: 1234,
contentType: 'application/json',
responseTime: 0.067,
host: 'acme.io',
webserver: 'cloudflare',
tech: ['Node.js', 'Express'],
gfPatterns: ['config', 'json'],
createdAt: '2024-12-27T14:31:00Z',
},
{
id: 7,
url: 'https://techstart.io/',
method: 'GET',
statusCode: 200,
title: 'TechStart - Innovation Hub',
contentLength: 34567,
contentType: 'text/html; charset=utf-8',
responseTime: 0.278,
host: 'techstart.io',
webserver: 'Apache/2.4.54',
tech: ['WordPress', 'PHP'],
createdAt: '2024-12-26T08:45:00Z',
},
{
id: 8,
url: 'https://techstart.io/admin',
method: 'GET',
statusCode: 302,
title: '',
contentLength: 0,
contentType: 'text/html',
responseTime: 0.045,
location: 'https://techstart.io/admin/login',
host: 'techstart.io',
webserver: 'Apache/2.4.54',
tech: ['WordPress', 'PHP'],
createdAt: '2024-12-26T08:46:00Z',
},
{
id: 9,
url: 'https://globalfinance.com/',
method: 'GET',
statusCode: 200,
title: 'Global Finance - Your Financial Partner',
contentLength: 56789,
contentType: 'text/html; charset=utf-8',
responseTime: 0.456,
host: 'globalfinance.com',
webserver: 'Microsoft-IIS/10.0',
tech: ['ASP.NET', 'C#', 'jQuery'],
createdAt: '2024-12-25T16:20:00Z',
},
{
id: 10,
url: 'https://globalfinance.com/.git/config',
method: 'GET',
statusCode: 200,
title: '',
contentLength: 456,
contentType: 'text/plain',
responseTime: 0.034,
host: 'globalfinance.com',
webserver: 'Microsoft-IIS/10.0',
gfPatterns: ['git', 'config'],
createdAt: '2024-12-25T16:21:00Z',
},
{
id: 11,
url: 'https://retailmax.com/',
method: 'GET',
statusCode: 200,
title: 'RetailMax - Shop Everything',
contentLength: 89012,
contentType: 'text/html; charset=utf-8',
responseTime: 0.567,
host: 'retailmax.com',
webserver: 'nginx/1.22.0',
tech: ['React', 'Redux', 'Node.js'],
createdAt: '2024-12-21T10:45:00Z',
},
{
id: 12,
url: 'https://retailmax.com/product?id=1',
method: 'GET',
statusCode: 200,
title: 'Product Detail - RetailMax',
contentLength: 23456,
contentType: 'text/html; charset=utf-8',
responseTime: 0.234,
host: 'retailmax.com',
webserver: 'nginx/1.22.0',
tech: ['React', 'Redux'],
gfPatterns: ['param', 'id'],
createdAt: '2024-12-21T10:46:00Z',
},
{
id: 13,
url: 'https://healthcareplus.com/',
method: 'GET',
statusCode: 200,
title: 'HealthCare Plus - Digital Health',
contentLength: 45678,
contentType: 'text/html; charset=utf-8',
responseTime: 0.345,
host: 'healthcareplus.com',
webserver: 'nginx/1.24.0',
tech: ['Angular', 'TypeScript'],
createdAt: '2024-12-23T11:00:00Z',
},
{
id: 14,
url: 'https://edutech.io/',
method: 'GET',
statusCode: 200,
title: 'EduTech - Learn Anywhere',
contentLength: 67890,
contentType: 'text/html; charset=utf-8',
responseTime: 0.289,
host: 'edutech.io',
webserver: 'cloudflare',
tech: ['Vue.js', 'Nuxt.js'],
createdAt: '2024-12-22T13:30:00Z',
},
{
id: 15,
url: 'https://cloudnine.host/',
method: 'GET',
statusCode: 200,
title: 'CloudNine Hosting',
contentLength: 34567,
contentType: 'text/html; charset=utf-8',
responseTime: 0.178,
host: 'cloudnine.host',
webserver: 'LiteSpeed',
tech: ['PHP', 'Laravel'],
createdAt: '2024-12-19T16:00:00Z',
},
]
export function getMockEndpoints(params?: {
page?: number
pageSize?: number
search?: string
}): GetEndpointsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
let filtered = mockEndpoints
if (search) {
filtered = mockEndpoints.filter(
ep =>
ep.url.toLowerCase().includes(search) ||
ep.title.toLowerCase().includes(search) ||
ep.host?.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const endpoints = filtered.slice(start, start + pageSize)
return {
endpoints,
total,
page,
pageSize,
totalPages,
}
}
export function getMockEndpointById(id: number): Endpoint | undefined {
return mockEndpoints.find(ep => ep.id === id)
}

View File

@@ -0,0 +1,145 @@
import type { Organization, OrganizationsResponse } from '@/types/organization.types'
export const mockOrganizations: Organization[] = [
{
id: 1,
name: 'Acme Corporation',
description: '全球领先的科技公司,专注于云计算和人工智能领域',
createdAt: '2024-01-15T08:30:00Z',
updatedAt: '2024-12-28T14:20:00Z',
targetCount: 12,
domainCount: 156,
endpointCount: 2341,
targets: [
{ id: 1, name: 'acme.com' },
{ id: 2, name: 'acme.io' },
],
},
{
id: 2,
name: 'TechStart Inc',
description: '创新型初创企业,主营 SaaS 产品开发',
createdAt: '2024-02-20T10:15:00Z',
updatedAt: '2024-12-27T09:45:00Z',
targetCount: 5,
domainCount: 78,
endpointCount: 892,
targets: [
{ id: 3, name: 'techstart.io' },
],
},
{
id: 3,
name: 'Global Finance Ltd',
description: '国际金融服务公司,提供银行和投资解决方案',
createdAt: '2024-03-10T14:00:00Z',
updatedAt: '2024-12-26T16:30:00Z',
targetCount: 8,
domainCount: 234,
endpointCount: 1567,
targets: [
{ id: 4, name: 'globalfinance.com' },
{ id: 5, name: 'gf-bank.net' },
],
},
{
id: 4,
name: 'HealthCare Plus',
description: '医疗健康科技公司,专注于数字化医疗解决方案',
createdAt: '2024-04-05T09:20:00Z',
updatedAt: '2024-12-25T11:10:00Z',
targetCount: 6,
domainCount: 89,
endpointCount: 723,
targets: [
{ id: 6, name: 'healthcareplus.com' },
],
},
{
id: 5,
name: 'EduTech Solutions',
description: '在线教育平台,提供 K-12 和职业培训课程',
createdAt: '2024-05-12T11:45:00Z',
updatedAt: '2024-12-24T13:55:00Z',
targetCount: 4,
domainCount: 45,
endpointCount: 456,
targets: [
{ id: 7, name: 'edutech.io' },
],
},
{
id: 6,
name: 'RetailMax',
description: '电子商务零售平台,覆盖多品类商品销售',
createdAt: '2024-06-08T16:30:00Z',
updatedAt: '2024-12-23T10:20:00Z',
targetCount: 15,
domainCount: 312,
endpointCount: 4521,
targets: [
{ id: 8, name: 'retailmax.com' },
{ id: 9, name: 'retailmax.cn' },
],
},
{
id: 7,
name: 'CloudNine Hosting',
description: '云托管服务提供商,提供 VPS 和专用服务器',
createdAt: '2024-07-20T08:00:00Z',
updatedAt: '2024-12-22T15:40:00Z',
targetCount: 3,
domainCount: 67,
endpointCount: 389,
targets: [
{ id: 10, name: 'cloudnine.host' },
],
},
{
id: 8,
name: 'MediaStream Corp',
description: '流媒体内容分发平台,提供视频和音频服务',
createdAt: '2024-08-15T12:10:00Z',
updatedAt: '2024-12-21T08:25:00Z',
targetCount: 7,
domainCount: 123,
endpointCount: 1234,
targets: [
{ id: 11, name: 'mediastream.tv' },
],
},
]
export function getMockOrganizations(params?: {
page?: number
pageSize?: number
search?: string
}): OrganizationsResponse<Organization> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
// 过滤搜索
let filtered = mockOrganizations
if (search) {
filtered = mockOrganizations.filter(
org =>
org.name.toLowerCase().includes(search) ||
org.description.toLowerCase().includes(search)
)
}
// 分页
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return {
results,
total,
page,
pageSize,
totalPages,
}
}

309
frontend/mock/data/scans.ts Normal file
View File

@@ -0,0 +1,309 @@
import type { ScanRecord, GetScansResponse, ScanStatus } from '@/types/scan.types'
import type { ScanStatistics } from '@/services/scan.service'
export const mockScans: ScanRecord[] = [
{
id: 1,
target: 1,
targetName: 'acme.com',
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,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-28T10:00:00Z',
status: 'completed',
progress: 100,
},
{
id: 2,
target: 2,
targetName: 'acme.io',
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,
},
},
engineIds: [1, 2],
engineNames: ['Subdomain Discovery', 'Web Crawling'],
createdAt: '2024-12-27T14:30:00Z',
status: 'running',
progress: 65,
currentStage: 'web_crawling',
stageProgress: {
subdomain_discovery: {
status: 'completed',
order: 0,
startedAt: '2024-12-27T14:30:00Z',
duration: 1200,
detail: 'Found 78 subdomains',
},
web_crawling: {
status: 'running',
order: 1,
startedAt: '2024-12-27T14:50:00Z',
},
},
},
{
id: 3,
target: 3,
targetName: 'techstart.io',
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,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-26T08:45:00Z',
status: 'completed',
progress: 100,
},
{
id: 4,
target: 4,
targetName: 'globalfinance.com',
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,
},
},
engineIds: [1],
engineNames: ['Subdomain Discovery'],
createdAt: '2024-12-25T16:20:00Z',
status: 'failed',
progress: 15,
errorMessage: 'Connection timeout: Unable to reach target',
},
{
id: 5,
target: 6,
targetName: 'healthcareplus.com',
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,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-29T09:00:00Z',
status: 'running',
progress: 25,
currentStage: 'subdomain_discovery',
stageProgress: {
subdomain_discovery: {
status: 'running',
order: 0,
startedAt: '2024-12-29T09:00:00Z',
},
web_crawling: {
status: 'pending',
order: 1,
},
nuclei_scan: {
status: 'pending',
order: 2,
},
},
},
{
id: 6,
target: 7,
targetName: 'edutech.io',
workerName: null,
summary: {
subdomains: 0,
websites: 0,
directories: 0,
endpoints: 0,
ips: 0,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
},
engineIds: [1, 2],
engineNames: ['Subdomain Discovery', 'Web Crawling'],
createdAt: '2024-12-29T10:30:00Z',
status: 'initiated',
progress: 0,
},
{
id: 7,
target: 8,
targetName: 'retailmax.com',
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,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-21T10:45:00Z',
status: 'completed',
progress: 100,
},
{
id: 8,
target: 11,
targetName: 'mediastream.tv',
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,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-29T08:00:00Z',
status: 'running',
progress: 45,
currentStage: 'web_crawling',
stageProgress: {
subdomain_discovery: {
status: 'completed',
order: 0,
startedAt: '2024-12-29T08:00:00Z',
duration: 900,
detail: 'Found 67 subdomains',
},
web_crawling: {
status: 'running',
order: 1,
startedAt: '2024-12-29T08:15:00Z',
},
nuclei_scan: {
status: 'pending',
order: 2,
},
},
},
]
export const mockScanStatistics: ScanStatistics = {
total: 156,
running: 3,
completed: 142,
failed: 11,
totalVulns: 89,
totalSubdomains: 4823,
totalEndpoints: 12456,
totalWebsites: 3421,
totalAssets: 21638,
}
export function getMockScans(params?: {
page?: number
pageSize?: number
status?: ScanStatus
search?: string
}): GetScansResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const status = params?.status
const search = params?.search?.toLowerCase() || ''
let filtered = mockScans
if (status) {
filtered = filtered.filter(scan => scan.status === status)
}
if (search) {
filtered = filtered.filter(scan =>
scan.targetName.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return {
results,
total,
page,
pageSize,
totalPages,
}
}
export function getMockScanById(id: number): ScanRecord | undefined {
return mockScans.find(scan => scan.id === id)
}

View File

@@ -0,0 +1,78 @@
import type { Subdomain, GetAllSubdomainsResponse } from '@/types/subdomain.types'
export const mockSubdomains: Subdomain[] = [
{ id: 1, name: 'acme.com', createdAt: '2024-12-28T10:00:00Z' },
{ id: 2, name: 'www.acme.com', createdAt: '2024-12-28T10:01:00Z' },
{ id: 3, name: 'api.acme.com', createdAt: '2024-12-28T10:02:00Z' },
{ id: 4, name: 'admin.acme.com', createdAt: '2024-12-28T10:03:00Z' },
{ id: 5, name: 'mail.acme.com', createdAt: '2024-12-28T10:04:00Z' },
{ id: 6, name: 'blog.acme.com', createdAt: '2024-12-28T10:05:00Z' },
{ id: 7, name: 'shop.acme.com', createdAt: '2024-12-28T10:06:00Z' },
{ id: 8, name: 'cdn.acme.com', createdAt: '2024-12-28T10:07:00Z' },
{ id: 9, name: 'static.acme.com', createdAt: '2024-12-28T10:08:00Z' },
{ id: 10, name: 'dev.acme.com', createdAt: '2024-12-28T10:09:00Z' },
{ id: 11, name: 'staging.acme.com', createdAt: '2024-12-28T10:10:00Z' },
{ id: 12, name: 'test.acme.com', createdAt: '2024-12-28T10:11:00Z' },
{ id: 13, name: 'acme.io', createdAt: '2024-12-27T14:30:00Z' },
{ id: 14, name: 'docs.acme.io', createdAt: '2024-12-27T14:31:00Z' },
{ id: 15, name: 'api.acme.io', createdAt: '2024-12-27T14:32:00Z' },
{ id: 16, name: 'status.acme.io', createdAt: '2024-12-27T14:33:00Z' },
{ id: 17, name: 'techstart.io', createdAt: '2024-12-26T08:45:00Z' },
{ id: 18, name: 'www.techstart.io', createdAt: '2024-12-26T08:46:00Z' },
{ id: 19, name: 'app.techstart.io', createdAt: '2024-12-26T08:47:00Z' },
{ id: 20, name: 'globalfinance.com', createdAt: '2024-12-25T16:20:00Z' },
{ id: 21, name: 'www.globalfinance.com', createdAt: '2024-12-25T16:21:00Z' },
{ id: 22, name: 'secure.globalfinance.com', createdAt: '2024-12-25T16:22:00Z' },
{ id: 23, name: 'portal.globalfinance.com', createdAt: '2024-12-25T16:23:00Z' },
{ id: 24, name: 'healthcareplus.com', createdAt: '2024-12-23T11:00:00Z' },
{ id: 25, name: 'www.healthcareplus.com', createdAt: '2024-12-23T11:01:00Z' },
{ id: 26, name: 'patient.healthcareplus.com', createdAt: '2024-12-23T11:02:00Z' },
{ id: 27, name: 'edutech.io', createdAt: '2024-12-22T13:30:00Z' },
{ id: 28, name: 'learn.edutech.io', createdAt: '2024-12-22T13:31:00Z' },
{ id: 29, name: 'retailmax.com', createdAt: '2024-12-21T10:45:00Z' },
{ id: 30, name: 'www.retailmax.com', createdAt: '2024-12-21T10:46:00Z' },
{ id: 31, name: 'm.retailmax.com', createdAt: '2024-12-21T10:47:00Z' },
{ id: 32, name: 'api.retailmax.com', createdAt: '2024-12-21T10:48:00Z' },
{ id: 33, name: 'cloudnine.host', createdAt: '2024-12-19T16:00:00Z' },
{ id: 34, name: 'panel.cloudnine.host', createdAt: '2024-12-19T16:01:00Z' },
{ id: 35, name: 'mediastream.tv', createdAt: '2024-12-18T09:30:00Z' },
{ id: 36, name: 'www.mediastream.tv', createdAt: '2024-12-18T09:31:00Z' },
{ id: 37, name: 'cdn.mediastream.tv', createdAt: '2024-12-18T09:32:00Z' },
{ id: 38, name: 'stream.mediastream.tv', createdAt: '2024-12-18T09:33:00Z' },
]
export function getMockSubdomains(params?: {
page?: number
pageSize?: number
search?: string
organizationId?: number
}): GetAllSubdomainsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
let filtered = mockSubdomains
if (search) {
filtered = mockSubdomains.filter(sub =>
sub.name.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const domains = filtered.slice(start, start + pageSize)
return {
domains,
total,
page,
pageSize,
totalPages,
}
}
export function getMockSubdomainById(id: number): Subdomain | undefined {
return mockSubdomains.find(sub => sub.id === id)
}

View File

@@ -0,0 +1,205 @@
import type { Target, TargetsResponse, TargetDetail } from '@/types/target.types'
export const mockTargets: Target[] = [
{
id: 1,
name: 'acme.com',
type: 'domain',
description: 'Acme Corporation 主站',
createdAt: '2024-01-15T08:30:00Z',
lastScannedAt: '2024-12-28T10:00:00Z',
organizations: [{ id: 1, name: 'Acme Corporation' }],
},
{
id: 2,
name: 'acme.io',
type: 'domain',
description: 'Acme Corporation 开发者平台',
createdAt: '2024-01-16T09:00:00Z',
lastScannedAt: '2024-12-27T14:30:00Z',
organizations: [{ id: 1, name: 'Acme Corporation' }],
},
{
id: 3,
name: 'techstart.io',
type: 'domain',
description: 'TechStart 官网',
createdAt: '2024-02-20T10:15:00Z',
lastScannedAt: '2024-12-26T08:45:00Z',
organizations: [{ id: 2, name: 'TechStart Inc' }],
},
{
id: 4,
name: 'globalfinance.com',
type: 'domain',
description: 'Global Finance 主站',
createdAt: '2024-03-10T14:00:00Z',
lastScannedAt: '2024-12-25T16:20:00Z',
organizations: [{ id: 3, name: 'Global Finance Ltd' }],
},
{
id: 5,
name: '192.168.1.0/24',
type: 'cidr',
description: '内网 IP 段',
createdAt: '2024-03-15T11:30:00Z',
lastScannedAt: '2024-12-24T09:15:00Z',
organizations: [{ id: 3, name: 'Global Finance Ltd' }],
},
{
id: 6,
name: 'healthcareplus.com',
type: 'domain',
description: 'HealthCare Plus 官网',
createdAt: '2024-04-05T09:20:00Z',
lastScannedAt: '2024-12-23T11:00:00Z',
organizations: [{ id: 4, name: 'HealthCare Plus' }],
},
{
id: 7,
name: 'edutech.io',
type: 'domain',
description: 'EduTech 在线教育平台',
createdAt: '2024-05-12T11:45:00Z',
lastScannedAt: '2024-12-22T13:30:00Z',
organizations: [{ id: 5, name: 'EduTech Solutions' }],
},
{
id: 8,
name: 'retailmax.com',
type: 'domain',
description: 'RetailMax 电商主站',
createdAt: '2024-06-08T16:30:00Z',
lastScannedAt: '2024-12-21T10:45:00Z',
organizations: [{ id: 6, name: 'RetailMax' }],
},
{
id: 9,
name: '10.0.0.1',
type: 'ip',
description: '核心服务器 IP',
createdAt: '2024-07-01T08:00:00Z',
lastScannedAt: '2024-12-20T14:20:00Z',
organizations: [{ id: 7, name: 'CloudNine Hosting' }],
},
{
id: 10,
name: 'cloudnine.host',
type: 'domain',
description: 'CloudNine 托管服务',
createdAt: '2024-07-20T08:00:00Z',
lastScannedAt: '2024-12-19T16:00:00Z',
organizations: [{ id: 7, name: 'CloudNine Hosting' }],
},
{
id: 11,
name: 'mediastream.tv',
type: 'domain',
description: 'MediaStream 流媒体平台',
createdAt: '2024-08-15T12:10:00Z',
lastScannedAt: '2024-12-18T09:30:00Z',
organizations: [{ id: 8, name: 'MediaStream Corp' }],
},
{
id: 12,
name: 'api.acme.com',
type: 'domain',
description: 'Acme API 服务',
createdAt: '2024-09-01T10:00:00Z',
lastScannedAt: '2024-12-17T11:15:00Z',
organizations: [{ id: 1, name: 'Acme Corporation' }],
},
]
export const mockTargetDetails: Record<number, TargetDetail> = {
1: {
...mockTargets[0],
summary: {
subdomains: 156,
websites: 89,
endpoints: 2341,
ips: 45,
vulnerabilities: {
total: 23,
critical: 1,
high: 4,
medium: 8,
low: 10,
},
},
},
2: {
...mockTargets[1],
summary: {
subdomains: 78,
websites: 45,
endpoints: 892,
ips: 23,
vulnerabilities: {
total: 12,
critical: 0,
high: 2,
medium: 5,
low: 5,
},
},
},
}
export function getMockTargets(params?: {
page?: number
pageSize?: number
search?: string
}): TargetsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
let filtered = mockTargets
if (search) {
filtered = mockTargets.filter(
target =>
target.name.toLowerCase().includes(search) ||
target.description?.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return {
results,
total,
page,
pageSize,
totalPages,
}
}
export function getMockTargetById(id: number): TargetDetail | undefined {
if (mockTargetDetails[id]) {
return mockTargetDetails[id]
}
const target = mockTargets.find(t => t.id === id)
if (target) {
return {
...target,
summary: {
subdomains: Math.floor(Math.random() * 100) + 10,
websites: Math.floor(Math.random() * 50) + 5,
endpoints: Math.floor(Math.random() * 1000) + 100,
ips: Math.floor(Math.random() * 30) + 5,
vulnerabilities: {
total: Math.floor(Math.random() * 20) + 1,
critical: Math.floor(Math.random() * 2),
high: Math.floor(Math.random() * 5),
medium: Math.floor(Math.random() * 8),
low: Math.floor(Math.random() * 10),
},
},
}
}
return undefined
}

View File

@@ -0,0 +1,275 @@
import type { Vulnerability, GetVulnerabilitiesResponse, VulnerabilitySeverity } from '@/types/vulnerability.types'
export const mockVulnerabilities: Vulnerability[] = [
{
id: 1,
target: 1,
url: 'https://acme.com/search?q=test',
vulnType: 'xss-reflected',
severity: 'critical',
source: 'dalfox',
cvssScore: 9.1,
description: 'Reflected XSS in search parameter',
rawOutput: {
type: 'R',
inject_type: 'inHTML-URL',
method: 'GET',
data: 'https://acme.com/search?q=<script>alert(1)</script>',
param: 'q',
payload: '<script>alert(1)</script>',
evidence: '<script>alert(1)</script>',
cwe: 'CWE-79',
},
createdAt: '2024-12-28T10:30:00Z',
},
{
id: 2,
target: 1,
url: 'https://api.acme.com/v1/users',
vulnType: 'CVE-2024-1234',
severity: 'high',
source: 'nuclei',
cvssScore: 8.5,
description: 'SQL Injection in user API endpoint',
rawOutput: {
'template-id': 'CVE-2024-1234',
'matched-at': 'https://api.acme.com/v1/users',
host: 'api.acme.com',
info: {
name: 'SQL Injection',
description: 'SQL injection vulnerability in user endpoint',
severity: 'high',
tags: ['sqli', 'cve'],
reference: ['https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-1234'],
classification: {
'cve-id': 'CVE-2024-1234',
'cwe-id': ['CWE-89'],
},
},
},
createdAt: '2024-12-28T10:45:00Z',
},
{
id: 3,
target: 1,
url: 'https://acme.com/login',
vulnType: 'xss-stored',
severity: 'high',
source: 'dalfox',
cvssScore: 8.2,
description: 'Stored XSS in user profile',
rawOutput: {
type: 'S',
inject_type: 'inHTML-TAG',
method: 'POST',
param: 'bio',
payload: '<img src=x onerror=alert(1)>',
},
createdAt: '2024-12-27T14:20:00Z',
},
{
id: 4,
target: 2,
url: 'https://acme.io/api/config',
vulnType: 'information-disclosure',
severity: 'medium',
source: 'nuclei',
cvssScore: 5.3,
description: 'Exposed configuration file',
rawOutput: {
'template-id': 'exposed-config',
'matched-at': 'https://acme.io/api/config',
host: 'acme.io',
info: {
name: 'Exposed Configuration',
description: 'Configuration file accessible without authentication',
severity: 'medium',
tags: ['exposure', 'config'],
},
},
createdAt: '2024-12-27T15:00:00Z',
},
{
id: 5,
target: 3,
url: 'https://techstart.io/admin',
vulnType: 'open-redirect',
severity: 'medium',
source: 'nuclei',
cvssScore: 4.7,
description: 'Open redirect vulnerability',
rawOutput: {
'template-id': 'open-redirect',
'matched-at': 'https://techstart.io/admin?redirect=evil.com',
host: 'techstart.io',
info: {
name: 'Open Redirect',
description: 'URL redirect without validation',
severity: 'medium',
tags: ['redirect'],
},
},
createdAt: '2024-12-26T09:30:00Z',
},
{
id: 6,
target: 4,
url: 'https://globalfinance.com/.git/config',
vulnType: 'git-config-exposure',
severity: 'high',
source: 'nuclei',
cvssScore: 7.5,
description: 'Git configuration file exposed',
rawOutput: {
'template-id': 'git-config',
'matched-at': 'https://globalfinance.com/.git/config',
host: 'globalfinance.com',
info: {
name: 'Git Config Exposure',
description: 'Git configuration file is publicly accessible',
severity: 'high',
tags: ['git', 'exposure'],
},
},
createdAt: '2024-12-25T11:15:00Z',
},
{
id: 7,
target: 8,
url: 'https://retailmax.com/product?id=1',
vulnType: 'sqli',
severity: 'critical',
source: 'nuclei',
cvssScore: 9.8,
description: 'SQL Injection in product parameter',
rawOutput: {
'template-id': 'generic-sqli',
'matched-at': "https://retailmax.com/product?id=1'",
host: 'retailmax.com',
info: {
name: 'SQL Injection',
description: 'SQL injection in product ID parameter',
severity: 'critical',
tags: ['sqli'],
classification: {
'cwe-id': ['CWE-89'],
},
},
},
createdAt: '2024-12-21T12:00:00Z',
},
{
id: 8,
target: 1,
url: 'https://acme.com/robots.txt',
vulnType: 'robots-txt-exposure',
severity: 'info',
source: 'nuclei',
description: 'Robots.txt file found',
rawOutput: {
'template-id': 'robots-txt',
'matched-at': 'https://acme.com/robots.txt',
host: 'acme.com',
info: {
name: 'Robots.txt',
description: 'Robots.txt file detected',
severity: 'info',
tags: ['misc'],
},
},
createdAt: '2024-12-28T10:00:00Z',
},
{
id: 9,
target: 2,
url: 'https://acme.io/sitemap.xml',
vulnType: 'sitemap-exposure',
severity: 'info',
source: 'nuclei',
description: 'Sitemap.xml file found',
rawOutput: {
'template-id': 'sitemap-xml',
'matched-at': 'https://acme.io/sitemap.xml',
host: 'acme.io',
info: {
name: 'Sitemap.xml',
description: 'Sitemap.xml file detected',
severity: 'info',
tags: ['misc'],
},
},
createdAt: '2024-12-27T14:00:00Z',
},
{
id: 10,
target: 3,
url: 'https://techstart.io/api/v2/debug',
vulnType: 'debug-endpoint',
severity: 'low',
source: 'nuclei',
cvssScore: 3.1,
description: 'Debug endpoint exposed',
rawOutput: {
'template-id': 'debug-endpoint',
'matched-at': 'https://techstart.io/api/v2/debug',
host: 'techstart.io',
info: {
name: 'Debug Endpoint',
description: 'Debug endpoint accessible in production',
severity: 'low',
tags: ['debug', 'exposure'],
},
},
createdAt: '2024-12-26T10:00:00Z',
},
]
export function getMockVulnerabilities(params?: {
page?: number
pageSize?: number
targetId?: number
severity?: VulnerabilitySeverity
search?: string
}): GetVulnerabilitiesResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const targetId = params?.targetId
const severity = params?.severity
const search = params?.search?.toLowerCase() || ''
let filtered = mockVulnerabilities
if (targetId) {
filtered = filtered.filter(v => v.target === targetId)
}
if (severity) {
filtered = filtered.filter(v => v.severity === severity)
}
if (search) {
filtered = filtered.filter(
v =>
v.url.toLowerCase().includes(search) ||
v.vulnType.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const vulnerabilities = filtered.slice(start, start + pageSize)
return {
vulnerabilities,
total,
page,
pageSize,
totalPages,
}
}
export function getMockVulnerabilityById(id: number): Vulnerability | undefined {
return mockVulnerabilities.find(v => v.id === id)
}

View File

@@ -0,0 +1,252 @@
import type { WebSite, WebSiteListResponse } from '@/types/website.types'
export const mockWebsites: WebSite[] = [
{
id: 1,
target: 1,
url: 'https://acme.com',
host: 'acme.com',
location: '',
title: 'Acme Corporation - Home',
webserver: 'nginx/1.24.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 45678,
responseBody: '<!DOCTYPE html>...',
tech: ['React', 'Next.js', 'Node.js', 'Tailwind CSS'],
vhost: false,
subdomain: 'acme.com',
createdAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
target: 1,
url: 'https://www.acme.com',
host: 'www.acme.com',
location: 'https://acme.com',
title: 'Acme Corporation - Home',
webserver: 'nginx/1.24.0',
contentType: 'text/html; charset=utf-8',
statusCode: 301,
contentLength: 0,
responseBody: '',
tech: [],
vhost: false,
subdomain: 'www.acme.com',
createdAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
target: 1,
url: 'https://api.acme.com',
host: 'api.acme.com',
location: '',
title: 'Acme API',
webserver: 'nginx/1.24.0',
contentType: 'application/json',
statusCode: 200,
contentLength: 234,
responseBody: '{"status":"ok","version":"1.0"}',
tech: ['Django', 'Python', 'PostgreSQL'],
vhost: false,
subdomain: 'api.acme.com',
createdAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
target: 1,
url: 'https://admin.acme.com',
host: 'admin.acme.com',
location: '',
title: 'Admin Panel - Acme',
webserver: 'nginx/1.24.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 23456,
responseBody: '<!DOCTYPE html>...',
tech: ['React', 'Ant Design'],
vhost: false,
subdomain: 'admin.acme.com',
createdAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
target: 2,
url: 'https://acme.io',
host: 'acme.io',
location: '',
title: 'Acme Developer Platform',
webserver: 'cloudflare',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 56789,
responseBody: '<!DOCTYPE html>...',
tech: ['Vue.js', 'Vitepress', 'CloudFlare'],
vhost: false,
subdomain: 'acme.io',
createdAt: '2024-12-27T14:30:00Z',
},
{
id: 6,
target: 2,
url: 'https://docs.acme.io',
host: 'docs.acme.io',
location: '',
title: 'Documentation - Acme.io',
webserver: 'cloudflare',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 67890,
responseBody: '<!DOCTYPE html>...',
tech: ['Vue.js', 'Vitepress'],
vhost: false,
subdomain: 'docs.acme.io',
createdAt: '2024-12-27T14:31:00Z',
},
{
id: 7,
target: 3,
url: 'https://techstart.io',
host: 'techstart.io',
location: '',
title: 'TechStart - Innovation Hub',
webserver: 'Apache/2.4.54',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 34567,
responseBody: '<!DOCTYPE html>...',
tech: ['WordPress', 'PHP', 'MySQL'],
vhost: false,
subdomain: 'techstart.io',
createdAt: '2024-12-26T08:45:00Z',
},
{
id: 8,
target: 4,
url: 'https://globalfinance.com',
host: 'globalfinance.com',
location: '',
title: 'Global Finance - Your Financial Partner',
webserver: 'Microsoft-IIS/10.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 56789,
responseBody: '<!DOCTYPE html>...',
tech: ['ASP.NET', 'C#', 'jQuery', 'SQL Server'],
vhost: false,
subdomain: 'globalfinance.com',
createdAt: '2024-12-25T16:20:00Z',
},
{
id: 9,
target: 6,
url: 'https://healthcareplus.com',
host: 'healthcareplus.com',
location: '',
title: 'HealthCare Plus - Digital Health',
webserver: 'nginx/1.24.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 45678,
responseBody: '<!DOCTYPE html>...',
tech: ['Angular', 'TypeScript', 'Node.js'],
vhost: false,
subdomain: 'healthcareplus.com',
createdAt: '2024-12-23T11:00:00Z',
},
{
id: 10,
target: 7,
url: 'https://edutech.io',
host: 'edutech.io',
location: '',
title: 'EduTech - Learn Anywhere',
webserver: 'cloudflare',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 67890,
responseBody: '<!DOCTYPE html>...',
tech: ['Vue.js', 'Nuxt.js', 'PostgreSQL'],
vhost: false,
subdomain: 'edutech.io',
createdAt: '2024-12-22T13:30:00Z',
},
{
id: 11,
target: 8,
url: 'https://retailmax.com',
host: 'retailmax.com',
location: '',
title: 'RetailMax - Shop Everything',
webserver: 'nginx/1.22.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 89012,
responseBody: '<!DOCTYPE html>...',
tech: ['React', 'Redux', 'Node.js', 'MongoDB'],
vhost: false,
subdomain: 'retailmax.com',
createdAt: '2024-12-21T10:45:00Z',
},
{
id: 12,
target: 10,
url: 'https://cloudnine.host',
host: 'cloudnine.host',
location: '',
title: 'CloudNine Hosting',
webserver: 'LiteSpeed',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 34567,
responseBody: '<!DOCTYPE html>...',
tech: ['PHP', 'Laravel', 'MySQL'],
vhost: false,
subdomain: 'cloudnine.host',
createdAt: '2024-12-19T16:00:00Z',
},
]
export function getMockWebsites(params?: {
page?: number
pageSize?: number
search?: string
targetId?: number
}): WebSiteListResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
const targetId = params?.targetId
let filtered = mockWebsites
if (targetId) {
filtered = filtered.filter(w => w.target === targetId)
}
if (search) {
filtered = filtered.filter(
w =>
w.url.toLowerCase().includes(search) ||
w.title.toLowerCase().includes(search) ||
w.host.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return {
results,
total,
page,
pageSize,
totalPages,
}
}
export function getMockWebsiteById(id: number): WebSite | undefined {
return mockWebsites.find(w => w.id === id)
}

71
frontend/mock/index.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Mock 数据统一导出
*
* 使用方式:
* import { USE_MOCK, mockData } from '@/mock'
*
* if (USE_MOCK) {
* return mockData.dashboard.assetStatistics
* }
*/
export { USE_MOCK, MOCK_DELAY, mockDelay } from './config'
// Dashboard
export {
mockDashboardStats,
mockAssetStatistics,
mockStatisticsHistory7Days,
mockStatisticsHistory30Days,
getMockStatisticsHistory,
} from './data/dashboard'
// Organizations
export {
mockOrganizations,
getMockOrganizations,
} from './data/organizations'
// Targets
export {
mockTargets,
mockTargetDetails,
getMockTargets,
getMockTargetById,
} from './data/targets'
// Scans
export {
mockScans,
mockScanStatistics,
getMockScans,
getMockScanById,
} from './data/scans'
// Vulnerabilities
export {
mockVulnerabilities,
getMockVulnerabilities,
getMockVulnerabilityById,
} from './data/vulnerabilities'
// Endpoints
export {
mockEndpoints,
getMockEndpoints,
getMockEndpointById,
} from './data/endpoints'
// Websites
export {
mockWebsites,
getMockWebsites,
getMockWebsiteById,
} from './data/websites'
// Subdomains
export {
mockSubdomains,
getMockSubdomains,
getMockSubdomainById,
} from './data/subdomains'

View File

@@ -3,9 +3,12 @@ import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
// Check if running on Vercel
const isVercel = process.env.VERCEL === '1';
const nextConfig: NextConfig = {
// Use standalone mode for Docker deployment
output: 'standalone',
// Use standalone mode for Docker deployment (not needed on Vercel)
...(isVercel ? {} : { output: 'standalone' }),
// Disable Next.js automatic add/remove trailing slash behavior
// Let us manually control URL format
skipTrailingSlashRedirect: true,
@@ -17,6 +20,10 @@ const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.*.*', '10.*.*.*', '172.16.*.*'],
async rewrites() {
// Skip rewrites on Vercel when using mock data
if (isVercel) {
return [];
}
// Use server service name in Docker environment, localhost for local development
const apiHost = process.env.API_HOST || 'localhost';
return [

View File

@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev:mock": "NEXT_PUBLIC_USE_MOCK=true next dev --turbopack",
"dev:noauth": "NEXT_PUBLIC_SKIP_AUTH=true next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",

View File

@@ -1,7 +1,12 @@
import { api } from '@/lib/api-client'
import type { DashboardStats, AssetStatistics, StatisticsHistoryItem } from '@/types/dashboard.types'
import { USE_MOCK, mockDelay, mockDashboardStats, mockAssetStatistics, getMockStatisticsHistory } from '@/mock'
export async function getDashboardStats(): Promise<DashboardStats> {
if (USE_MOCK) {
await mockDelay()
return mockDashboardStats
}
const res = await api.get<DashboardStats>('/dashboard/stats/')
return res.data
}
@@ -10,6 +15,10 @@ export async function getDashboardStats(): Promise<DashboardStats> {
* Get asset statistics data (pre-aggregated)
*/
export async function getAssetStatistics(): Promise<AssetStatistics> {
if (USE_MOCK) {
await mockDelay()
return mockAssetStatistics
}
const res = await api.get<AssetStatistics>('/assets/statistics/')
return res.data
}
@@ -18,6 +27,10 @@ export async function getAssetStatistics(): Promise<AssetStatistics> {
* Get statistics history data (for line charts)
*/
export async function getStatisticsHistory(days: number = 7): Promise<StatisticsHistoryItem[]> {
if (USE_MOCK) {
await mockDelay()
return getMockStatisticsHistory(days)
}
const res = await api.get<StatisticsHistoryItem[]>('/assets/statistics/history/', {
params: { days }
})

View File

@@ -8,6 +8,7 @@ import type {
BatchDeleteEndpointsRequest,
BatchDeleteEndpointsResponse
} from "@/types/endpoint.types"
import { USE_MOCK, mockDelay, getMockEndpoints, getMockEndpointById } from '@/mock'
// Bulk create endpoints response type
export interface BulkCreateEndpointsResponse {
@@ -38,6 +39,12 @@ export class EndpointService {
* @returns Promise<Endpoint>
*/
static async getEndpointById(id: number): Promise<Endpoint> {
if (USE_MOCK) {
await mockDelay()
const endpoint = getMockEndpointById(id)
if (!endpoint) throw new Error('Endpoint not found')
return endpoint
}
const response = await api.get<Endpoint>(`/endpoints/${id}/`)
return response.data
}
@@ -48,6 +55,10 @@ export class EndpointService {
* @returns Promise<GetEndpointsResponse>
*/
static async getEndpoints(params: GetEndpointsRequest): Promise<GetEndpointsResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockEndpoints(params)
}
// api-client.ts automatically converts camelCase params to snake_case
const response = await api.get<GetEndpointsResponse>('/endpoints/', {
params

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client"
import type { Organization, OrganizationsResponse } from "@/types/organization.types"
import { USE_MOCK, mockDelay, getMockOrganizations, mockOrganizations } from '@/mock'
export class OrganizationService {
@@ -18,6 +19,10 @@ export class OrganizationService {
pageSize?: number
search?: string
}): Promise<OrganizationsResponse<Organization>> {
if (USE_MOCK) {
await mockDelay()
return getMockOrganizations(params)
}
const response = await api.get<OrganizationsResponse<Organization>>(
'/organizations/',
{ params }
@@ -31,6 +36,12 @@ export class OrganizationService {
* @returns Promise<Organization>
*/
static async getOrganizationById(id: string | number): Promise<Organization> {
if (USE_MOCK) {
await mockDelay()
const org = mockOrganizations.find(o => o.id === Number(id))
if (!org) throw new Error('Organization not found')
return org
}
const response = await api.get<Organization>(`/organizations/${id}/`)
return response.data
}

View File

@@ -8,11 +8,16 @@ import type {
QuickScanResponse,
ScanRecord
} from '@/types/scan.types'
import { USE_MOCK, mockDelay, getMockScans, getMockScanById, mockScanStatistics } from '@/mock'
/**
* Get scan list
*/
export async function getScans(params?: GetScansParams): Promise<GetScansResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockScans(params)
}
const res = await api.get<GetScansResponse>('/scans/', { params })
return res.data
}
@@ -23,6 +28,12 @@ export async function getScans(params?: GetScansParams): Promise<GetScansRespons
* @returns Scan details
*/
export async function getScan(id: number): Promise<ScanRecord> {
if (USE_MOCK) {
await mockDelay()
const scan = getMockScanById(id)
if (!scan) throw new Error('Scan not found')
return scan
}
const res = await api.get<ScanRecord>(`/scans/${id}/`)
return res.data
}
@@ -95,6 +106,10 @@ export interface ScanStatistics {
* @returns Statistics data
*/
export async function getScanStatistics(): Promise<ScanStatistics> {
if (USE_MOCK) {
await mockDelay()
return mockScanStatistics
}
const res = await api.get<ScanStatistics>('/scans/statistics/')
return res.data
}

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client"
import type { Subdomain, GetSubdomainsParams, GetSubdomainsResponse, GetAllSubdomainsParams, GetAllSubdomainsResponse, GetSubdomainByIDResponse, BatchCreateSubdomainsResponse } from "@/types/subdomain.types"
import { USE_MOCK, mockDelay, getMockSubdomains, getMockSubdomainById } from '@/mock'
// Bulk create subdomains response type
export interface BulkCreateSubdomainsResponse {
@@ -48,6 +49,12 @@ export class SubdomainService {
* Get single subdomain details
*/
static async getSubdomainById(id: string | number): Promise<GetSubdomainByIDResponse> {
if (USE_MOCK) {
await mockDelay()
const subdomain = getMockSubdomainById(Number(id))
if (!subdomain) throw new Error('Subdomain not found')
return subdomain
}
const response = await api.get<GetSubdomainByIDResponse>(`/domains/${id}/`)
return response.data
}
@@ -164,6 +171,10 @@ export class SubdomainService {
/** Get all subdomains list (server-side pagination) */
static async getAllSubdomains(params?: GetAllSubdomainsParams): Promise<GetAllSubdomainsResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockSubdomains(params)
}
const response = await api.get<GetAllSubdomainsResponse>('/domains/', {
params: {
page: params?.page || 1,

View File

@@ -12,11 +12,16 @@ import type {
BatchCreateTargetsRequest,
BatchCreateTargetsResponse,
} from '@/types/target.types'
import { USE_MOCK, mockDelay, getMockTargets, getMockTargetById } from '@/mock'
/**
* Get all targets list (paginated)
*/
export async function getTargets(page = 1, pageSize = 10, search?: string): Promise<TargetsResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockTargets({ page, pageSize, search })
}
const response = await api.get<TargetsResponse>('/targets/', {
params: {
page,
@@ -31,6 +36,12 @@ export async function getTargets(page = 1, pageSize = 10, search?: string): Prom
* Get single target details
*/
export async function getTargetById(id: number): Promise<Target> {
if (USE_MOCK) {
await mockDelay()
const target = getMockTargetById(id)
if (!target) throw new Error('Target not found')
return target
}
const response = await api.get<Target>(`/targets/${id}/`)
return response.data
}

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client"
import type { GetVulnerabilitiesParams } from "@/types/vulnerability.types"
import type { GetVulnerabilitiesParams, Vulnerability } from "@/types/vulnerability.types"
import { USE_MOCK, mockDelay, getMockVulnerabilities } from '@/mock'
export class VulnerabilityService {
/** Get all vulnerabilities list (used by global vulnerabilities page) */
@@ -7,12 +8,22 @@ export class VulnerabilityService {
params: GetVulnerabilitiesParams,
filter?: string,
): Promise<any> {
if (USE_MOCK) {
await mockDelay()
return getMockVulnerabilities(params)
}
const response = await api.get(`/assets/vulnerabilities/`, {
params: { ...params, filter },
})
return response.data
}
/** Get single vulnerability by ID */
static async getVulnerabilityById(id: number): Promise<Vulnerability> {
const response = await api.get(`/assets/vulnerabilities/${id}/`)
return response.data
}
/** Get vulnerability snapshot list by scan task (used by scan history page) */
static async getVulnerabilitiesByScanId(
scanId: number,

View File

@@ -11,9 +11,9 @@ export interface SearchResult {
}
export interface Vulnerability {
id?: number
name: string
severity: 'critical' | 'high' | 'medium' | 'low' | 'info'
source: string
severity: 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown'
vulnType: string
url?: string
}

9
frontend/vercel.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "nextjs",
"buildCommand": "pnpm build",
"installCommand": "pnpm install",
"env": {
"NEXT_PUBLIC_USE_MOCK": "true"
}
}