mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 19:53:11 +08:00
Compare commits
14 Commits
v1.3.16-de
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0111adf2 | ||
|
|
161ee9a2b1 | ||
|
|
0cf75585d5 | ||
|
|
1d8d5f51d9 | ||
|
|
3f8de07c8c | ||
|
|
cd5c2b9f11 | ||
|
|
54786c22dd | ||
|
|
d468f975ab | ||
|
|
a85a12b8ad | ||
|
|
a8b0d97b7b | ||
|
|
b8504921c2 | ||
|
|
ecfc1822fb | ||
|
|
81633642e6 | ||
|
|
6ff86e14ec |
@@ -69,6 +69,12 @@
|
||||
- **自定义流程** - YAML 配置扫描流程,灵活编排
|
||||
- **定时扫描** - Cron 表达式配置,自动化周期扫描
|
||||
|
||||
### 🚫 黑名单过滤
|
||||
- **两层黑名单** - 全局黑名单 + Target 级黑名单,灵活控制扫描范围
|
||||
- **智能规则识别** - 自动识别域名通配符(`*.gov`)、IP、CIDR 网段
|
||||
- **敏感目标保护** - 过滤政府、军事、教育等敏感域名,防止误扫
|
||||
- **内网过滤** - 支持 `10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16` 等私有网段
|
||||
|
||||
### 🔖 指纹识别
|
||||
- **多源指纹库** - 内置 EHole、Goby、Wappalyzer、Fingers、FingerPrintHub、ARL 等 2.7W+ 指纹规则
|
||||
- **自动识别** - 扫描流程自动执行,识别 Web 应用技术栈
|
||||
|
||||
104
backend/apps/asset/migrations/0002_create_search_views.py
Normal file
104
backend/apps/asset/migrations/0002_create_search_views.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
创建资产搜索物化视图(使用 pg_ivm 增量维护)
|
||||
|
||||
这些视图用于资产搜索功能,提供高性能的全文搜索能力。
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""创建资产搜索所需的增量物化视图"""
|
||||
|
||||
dependencies = [
|
||||
('asset', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. 确保 pg_ivm 扩展已安装
|
||||
migrations.RunSQL(
|
||||
sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;",
|
||||
reverse_sql="DROP EXTENSION IF EXISTS pg_ivm;",
|
||||
),
|
||||
|
||||
# 2. 创建 Website 搜索视图
|
||||
# 注意:pg_ivm 不支持 ArrayField,所以 tech 字段需要从原表 JOIN 获取
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
SELECT pgivm.create_immv('asset_search_view', $$
|
||||
SELECT
|
||||
w.id,
|
||||
w.url,
|
||||
w.host,
|
||||
w.title,
|
||||
w.status_code,
|
||||
w.response_headers,
|
||||
w.response_body,
|
||||
w.content_type,
|
||||
w.content_length,
|
||||
w.webserver,
|
||||
w.location,
|
||||
w.vhost,
|
||||
w.created_at,
|
||||
w.target_id
|
||||
FROM website w
|
||||
$$);
|
||||
""",
|
||||
reverse_sql="DROP TABLE IF EXISTS asset_search_view CASCADE;",
|
||||
),
|
||||
|
||||
# 3. 创建 Endpoint 搜索视图
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
SELECT pgivm.create_immv('endpoint_search_view', $$
|
||||
SELECT
|
||||
e.id,
|
||||
e.url,
|
||||
e.host,
|
||||
e.title,
|
||||
e.status_code,
|
||||
e.response_headers,
|
||||
e.response_body,
|
||||
e.content_type,
|
||||
e.content_length,
|
||||
e.webserver,
|
||||
e.location,
|
||||
e.vhost,
|
||||
e.created_at,
|
||||
e.target_id
|
||||
FROM endpoint e
|
||||
$$);
|
||||
""",
|
||||
reverse_sql="DROP TABLE IF EXISTS endpoint_search_view CASCADE;",
|
||||
),
|
||||
|
||||
# 4. 为搜索视图创建索引(加速查询)
|
||||
migrations.RunSQL(
|
||||
sql=[
|
||||
# Website 搜索视图索引
|
||||
"CREATE INDEX IF NOT EXISTS asset_search_view_host_idx ON asset_search_view (host);",
|
||||
"CREATE INDEX IF NOT EXISTS asset_search_view_url_idx ON asset_search_view (url);",
|
||||
"CREATE INDEX IF NOT EXISTS asset_search_view_title_idx ON asset_search_view (title);",
|
||||
"CREATE INDEX IF NOT EXISTS asset_search_view_status_idx ON asset_search_view (status_code);",
|
||||
"CREATE INDEX IF NOT EXISTS asset_search_view_created_idx ON asset_search_view (created_at DESC);",
|
||||
# Endpoint 搜索视图索引
|
||||
"CREATE INDEX IF NOT EXISTS endpoint_search_view_host_idx ON endpoint_search_view (host);",
|
||||
"CREATE INDEX IF NOT EXISTS endpoint_search_view_url_idx ON endpoint_search_view (url);",
|
||||
"CREATE INDEX IF NOT EXISTS endpoint_search_view_title_idx ON endpoint_search_view (title);",
|
||||
"CREATE INDEX IF NOT EXISTS endpoint_search_view_status_idx ON endpoint_search_view (status_code);",
|
||||
"CREATE INDEX IF NOT EXISTS endpoint_search_view_created_idx ON endpoint_search_view (created_at DESC);",
|
||||
],
|
||||
reverse_sql=[
|
||||
"DROP INDEX IF EXISTS asset_search_view_host_idx;",
|
||||
"DROP INDEX IF EXISTS asset_search_view_url_idx;",
|
||||
"DROP INDEX IF EXISTS asset_search_view_title_idx;",
|
||||
"DROP INDEX IF EXISTS asset_search_view_status_idx;",
|
||||
"DROP INDEX IF EXISTS asset_search_view_created_idx;",
|
||||
"DROP INDEX IF EXISTS endpoint_search_view_host_idx;",
|
||||
"DROP INDEX IF EXISTS endpoint_search_view_url_idx;",
|
||||
"DROP INDEX IF EXISTS endpoint_search_view_title_idx;",
|
||||
"DROP INDEX IF EXISTS endpoint_search_view_status_idx;",
|
||||
"DROP INDEX IF EXISTS endpoint_search_view_created_idx;",
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -16,10 +16,9 @@ class GobyFingerprintService(BaseFingerprintService):
|
||||
"""
|
||||
校验单条 Goby 指纹
|
||||
|
||||
校验规则:
|
||||
- name 字段必须存在且非空
|
||||
- logic 字段必须存在
|
||||
- rule 字段必须是数组
|
||||
支持两种格式:
|
||||
1. 标准格式: {"name": "...", "logic": "...", "rule": [...]}
|
||||
2. JSONL 格式: {"product": "...", "rule": "..."}
|
||||
|
||||
Args:
|
||||
item: 单条指纹数据
|
||||
@@ -27,25 +26,43 @@ class GobyFingerprintService(BaseFingerprintService):
|
||||
Returns:
|
||||
bool: 是否有效
|
||||
"""
|
||||
# 标准格式:name + logic + rule(数组)
|
||||
name = item.get('name', '')
|
||||
logic = item.get('logic', '')
|
||||
rule = item.get('rule')
|
||||
return bool(name and str(name).strip()) and bool(logic) and isinstance(rule, list)
|
||||
if name and item.get('logic') is not None and isinstance(item.get('rule'), list):
|
||||
return bool(str(name).strip())
|
||||
|
||||
# JSONL 格式:product + rule(字符串)
|
||||
product = item.get('product', '')
|
||||
rule = item.get('rule', '')
|
||||
return bool(product and str(product).strip() and rule and str(rule).strip())
|
||||
|
||||
def to_model_data(self, item: dict) -> dict:
|
||||
"""
|
||||
转换 Goby JSON 格式为 Model 字段
|
||||
|
||||
支持两种输入格式:
|
||||
1. 标准格式: {"name": "...", "logic": "...", "rule": [...]}
|
||||
2. JSONL 格式: {"product": "...", "rule": "..."}
|
||||
|
||||
Args:
|
||||
item: 原始 Goby JSON 数据
|
||||
|
||||
Returns:
|
||||
dict: Model 字段数据
|
||||
"""
|
||||
# 标准格式
|
||||
if 'name' in item and isinstance(item.get('rule'), list):
|
||||
return {
|
||||
'name': str(item.get('name', '')).strip(),
|
||||
'logic': item.get('logic', ''),
|
||||
'rule': item.get('rule', []),
|
||||
}
|
||||
|
||||
# JSONL 格式:将 rule 字符串转为单元素数组
|
||||
return {
|
||||
'name': str(item.get('name', '')).strip(),
|
||||
'logic': item.get('logic', ''),
|
||||
'rule': item.get('rule', []),
|
||||
'name': str(item.get('product', '')).strip(),
|
||||
'logic': 'or', # JSONL 格式默认 or 逻辑
|
||||
'rule': [item.get('rule', '')] if item.get('rule') else [],
|
||||
}
|
||||
|
||||
def get_export_data(self) -> list:
|
||||
|
||||
@@ -139,7 +139,7 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
|
||||
POST /api/engine/fingerprints/{type}/import_file/
|
||||
|
||||
请求格式:multipart/form-data
|
||||
- file: JSON 文件
|
||||
- file: JSON 文件(支持标准 JSON 和 JSONL 格式)
|
||||
|
||||
返回:同 batch_create
|
||||
"""
|
||||
@@ -148,9 +148,12 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
|
||||
raise ValidationError('缺少文件')
|
||||
|
||||
try:
|
||||
json_data = json.load(file)
|
||||
content = file.read().decode('utf-8')
|
||||
json_data = self._parse_json_content(content)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValidationError(f'无效的 JSON 格式: {e}')
|
||||
except UnicodeDecodeError as e:
|
||||
raise ValidationError(f'文件编码错误: {e}')
|
||||
|
||||
fingerprints = self.parse_import_data(json_data)
|
||||
if not fingerprints:
|
||||
@@ -159,6 +162,41 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
|
||||
result = self.get_service().batch_create_fingerprints(fingerprints)
|
||||
return success_response(data=result, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
def _parse_json_content(self, content: str):
|
||||
"""
|
||||
解析 JSON 内容,支持标准 JSON 和 JSONL 格式
|
||||
|
||||
Args:
|
||||
content: 文件内容字符串
|
||||
|
||||
Returns:
|
||||
解析后的数据(list 或 dict)
|
||||
"""
|
||||
content = content.strip()
|
||||
|
||||
# 尝试标准 JSON 解析
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试 JSONL 格式(每行一个 JSON 对象)
|
||||
lines = content.split('\n')
|
||||
result = []
|
||||
for i, line in enumerate(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
result.append(json.loads(line))
|
||||
except json.JSONDecodeError as e:
|
||||
raise json.JSONDecodeError(f'第 {i + 1} 行解析失败: {e.msg}', e.doc, e.pos)
|
||||
|
||||
if not result:
|
||||
raise json.JSONDecodeError('文件为空或格式无效', content, 0)
|
||||
|
||||
return result
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk-delete')
|
||||
def bulk_delete(self, request):
|
||||
"""
|
||||
|
||||
@@ -21,9 +21,6 @@ urlpatterns = [
|
||||
|
||||
# 标记全部已读
|
||||
path('mark-all-as-read/', NotificationMarkAllAsReadView.as_view(), name='mark-all-as-read'),
|
||||
|
||||
# 测试通知
|
||||
path('test/', views.notifications_test, name='test'),
|
||||
]
|
||||
|
||||
# WebSocket 实时通知路由在 routing.py 中定义:ws://host/ws/notifications/
|
||||
|
||||
@@ -23,45 +23,7 @@ from .services import NotificationService, NotificationSettingsService
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def notifications_test(request):
|
||||
"""
|
||||
测试通知推送
|
||||
"""
|
||||
try:
|
||||
from .services import create_notification
|
||||
from django.http import JsonResponse
|
||||
|
||||
level_param = request.GET.get('level', NotificationLevel.LOW)
|
||||
try:
|
||||
level_choice = NotificationLevel(level_param)
|
||||
except ValueError:
|
||||
level_choice = NotificationLevel.LOW
|
||||
|
||||
title = request.GET.get('title') or "测试通知"
|
||||
message = request.GET.get('message') or "这是一条测试通知消息"
|
||||
|
||||
# 创建测试通知
|
||||
notification = create_notification(
|
||||
title=title,
|
||||
message=message,
|
||||
level=level_choice
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': '测试通知已发送',
|
||||
'notification_id': notification.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送测试通知失败: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
# build_api_response 已废弃,请使用 success_response/error_response
|
||||
|
||||
|
||||
def _parse_bool(value: str | None) -> bool | None:
|
||||
|
||||
@@ -65,7 +65,7 @@ class ScanHistorySerializer(serializers.ModelSerializer):
|
||||
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
|
||||
"""快速扫描序列化器"""
|
||||
|
||||
MAX_BATCH_SIZE = 1000
|
||||
MAX_BATCH_SIZE = 5000
|
||||
|
||||
targets = serializers.ListField(
|
||||
child=serializers.DictField(),
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"""导出 Endpoint URL 到文件的 Task
|
||||
|
||||
使用 TargetExportService 统一处理导出逻辑和默认值回退
|
||||
数据源: Endpoint.url
|
||||
|
||||
数据源优先级(回退链):
|
||||
1. Endpoint.url - 最精细的 URL(含路径、参数等)
|
||||
2. WebSite.url - 站点级别 URL
|
||||
3. 默认生成 - 根据 Target 类型生成 http(s)://target_name
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from prefect import task
|
||||
|
||||
from apps.asset.models import Endpoint
|
||||
from apps.asset.models import Endpoint, WebSite
|
||||
from apps.scan.services.target_export_service import create_export_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,13 +28,10 @@ def export_endpoints_task(
|
||||
) -> Dict[str, object]:
|
||||
"""导出目标下的所有 Endpoint URL 到文本文件。
|
||||
|
||||
数据源: Endpoint.url
|
||||
|
||||
懒加载模式:
|
||||
- 如果数据库为空,根据 Target 类型生成默认 URL
|
||||
- DOMAIN: http(s)://domain
|
||||
- IP: http(s)://ip
|
||||
- CIDR: 展开为所有 IP 的 URL
|
||||
数据源优先级(回退链):
|
||||
1. Endpoint 表 - 最精细的 URL(含路径、参数等)
|
||||
2. WebSite 表 - 站点级别 URL
|
||||
3. 默认生成 - 根据 Target 类型生成 http(s)://target_name
|
||||
|
||||
Args:
|
||||
target_id: 目标 ID
|
||||
@@ -41,24 +43,55 @@ def export_endpoints_task(
|
||||
"success": bool,
|
||||
"output_file": str,
|
||||
"total_count": int,
|
||||
"source": str, # 数据来源: "endpoint" | "website" | "default"
|
||||
}
|
||||
"""
|
||||
# 构建数据源 queryset(Task 层决定数据源)
|
||||
queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
|
||||
# 使用工厂函数创建导出服务
|
||||
export_service = create_export_service(target_id)
|
||||
output_path = Path(output_file)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. 优先从 Endpoint 表导出
|
||||
endpoint_queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
result = export_service.export_urls(
|
||||
target_id=target_id,
|
||||
output_path=output_file,
|
||||
queryset=queryset,
|
||||
queryset=endpoint_queryset,
|
||||
batch_size=batch_size
|
||||
)
|
||||
|
||||
# 保持返回值格式不变(向后兼容)
|
||||
if result['total_count'] > 0:
|
||||
logger.info("从 Endpoint 表导出 %d 条 URL", result['total_count'])
|
||||
return {
|
||||
"success": True,
|
||||
"output_file": result['output_file'],
|
||||
"total_count": result['total_count'],
|
||||
"source": "endpoint",
|
||||
}
|
||||
|
||||
# 2. Endpoint 为空,回退到 WebSite 表
|
||||
logger.info("Endpoint 表为空,回退到 WebSite 表")
|
||||
website_queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
result = export_service.export_urls(
|
||||
target_id=target_id,
|
||||
output_path=output_file,
|
||||
queryset=website_queryset,
|
||||
batch_size=batch_size
|
||||
)
|
||||
|
||||
if result['total_count'] > 0:
|
||||
logger.info("从 WebSite 表导出 %d 条 URL", result['total_count'])
|
||||
return {
|
||||
"success": True,
|
||||
"output_file": result['output_file'],
|
||||
"total_count": result['total_count'],
|
||||
"source": "website",
|
||||
}
|
||||
|
||||
# 3. WebSite 也为空,生成默认 URL(export_urls 内部已处理)
|
||||
logger.info("WebSite 表也为空,使用默认 URL 生成")
|
||||
return {
|
||||
"success": result['success'],
|
||||
"success": True,
|
||||
"output_file": result['output_file'],
|
||||
"total_count": result['total_count'],
|
||||
"source": "default",
|
||||
}
|
||||
|
||||
@@ -238,15 +238,39 @@ export function ImportFingerprintDialog({
|
||||
// Frontend basic validation for JSON files
|
||||
try {
|
||||
const text = await file.text()
|
||||
const json = JSON.parse(text)
|
||||
let json: any
|
||||
|
||||
// Try standard JSON first
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch {
|
||||
// If standard JSON fails, try JSONL format (for goby)
|
||||
if (fingerprintType === "goby") {
|
||||
const lines = text.trim().split('\n').filter(line => line.trim())
|
||||
if (lines.length === 0) {
|
||||
toast.error(t("import.emptyData"))
|
||||
return
|
||||
}
|
||||
// Parse each line as JSON
|
||||
json = lines.map((line, index) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch {
|
||||
throw new Error(`Line ${index + 1}: Invalid JSON`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
throw new Error("Invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
const validation = config.validate(json)
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(tToast("invalidJsonFile"))
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || tToast("invalidJsonFile"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ function TargetNameCell({
|
||||
return (
|
||||
<div className="group flex items-start gap-1 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => navigate(`/target/${targetId}/website/`)}
|
||||
onClick={() => navigate(`/target/${targetId}/overview/`)}
|
||||
className="text-sm font-medium hover:text-primary hover:underline underline-offset-2 transition-colors cursor-pointer text-left break-all leading-relaxed whitespace-normal"
|
||||
>
|
||||
{name}
|
||||
@@ -251,7 +251,7 @@ export const createTargetColumns = ({
|
||||
cell: ({ row }) => (
|
||||
<TargetRowActions
|
||||
target={row.original}
|
||||
onView={() => navigate(`/target/${row.original.id}/website/`)}
|
||||
onView={() => navigate(`/target/${row.original.id}/overview/`)}
|
||||
onDelete={() => handleDelete(row.original)}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconSearch, IconLoader2 } from "@tabler/icons-react"
|
||||
import { IconSearch, IconLoader2, IconPlus } from "@tabler/icons-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -74,6 +74,7 @@ export function TargetsDataTable({
|
||||
// 自定义添加按钮(支持 onAddHover)
|
||||
const addButton = onAddNew ? (
|
||||
<Button onClick={onAddNew} onMouseEnter={onAddHover} size="sm">
|
||||
<IconPlus className="h-4 w-4" />
|
||||
{addButtonText || tTarget("createTarget")}
|
||||
</Button>
|
||||
) : undefined
|
||||
|
||||
@@ -298,7 +298,7 @@ export function TargetOverview({ targetId }: TargetOverviewProps) {
|
||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-red-500" />
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">{t("vulnerabilitiesTitle")}</CardTitle>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
|
||||
69
frontend/mock/data/blacklist.ts
Normal file
69
frontend/mock/data/blacklist.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Blacklist Mock Data
|
||||
*
|
||||
* 黑名单规则 mock 数据
|
||||
* - 全局黑名单:适用于所有 Target
|
||||
* - Target 黑名单:仅适用于特定 Target
|
||||
*/
|
||||
|
||||
export interface BlacklistResponse {
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
export interface UpdateBlacklistRequest {
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
// 全局黑名单 mock 数据
|
||||
let mockGlobalBlacklistPatterns: string[] = [
|
||||
'*.gov',
|
||||
'*.edu',
|
||||
'*.mil',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
]
|
||||
|
||||
// Target 黑名单 mock 数据(按 targetId 存储)
|
||||
const mockTargetBlacklistPatterns: Record<number, string[]> = {
|
||||
1: ['*.internal.example.com', '192.168.1.0/24'],
|
||||
2: ['cdn.example.com', '*.cdn.*'],
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局黑名单
|
||||
*/
|
||||
export function getMockGlobalBlacklist(): BlacklistResponse {
|
||||
return {
|
||||
patterns: [...mockGlobalBlacklistPatterns],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新全局黑名单(全量替换)
|
||||
*/
|
||||
export function updateMockGlobalBlacklist(data: UpdateBlacklistRequest): BlacklistResponse {
|
||||
mockGlobalBlacklistPatterns = [...data.patterns]
|
||||
return {
|
||||
patterns: mockGlobalBlacklistPatterns,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Target 黑名单
|
||||
*/
|
||||
export function getMockTargetBlacklist(targetId: number): BlacklistResponse {
|
||||
return {
|
||||
patterns: mockTargetBlacklistPatterns[targetId] ? [...mockTargetBlacklistPatterns[targetId]] : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Target 黑名单(全量替换)
|
||||
*/
|
||||
export function updateMockTargetBlacklist(targetId: number, data: UpdateBlacklistRequest): BlacklistResponse {
|
||||
mockTargetBlacklistPatterns[targetId] = [...data.patterns]
|
||||
return {
|
||||
patterns: mockTargetBlacklistPatterns[targetId],
|
||||
}
|
||||
}
|
||||
@@ -182,3 +182,11 @@ export {
|
||||
getMockNotificationSettings,
|
||||
updateMockNotificationSettings,
|
||||
} from './data/notification-settings'
|
||||
|
||||
// Blacklist
|
||||
export {
|
||||
getMockGlobalBlacklist,
|
||||
updateMockGlobalBlacklist,
|
||||
getMockTargetBlacklist,
|
||||
updateMockTargetBlacklist,
|
||||
} from './data/blacklist'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from '@/lib/api-client'
|
||||
import { USE_MOCK, mockDelay, getMockGlobalBlacklist, updateMockGlobalBlacklist } from '@/mock'
|
||||
|
||||
export interface GlobalBlacklistResponse {
|
||||
patterns: string[]
|
||||
@@ -12,6 +13,10 @@ export interface UpdateGlobalBlacklistRequest {
|
||||
* Get global blacklist rules
|
||||
*/
|
||||
export async function getGlobalBlacklist(): Promise<GlobalBlacklistResponse> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
return getMockGlobalBlacklist()
|
||||
}
|
||||
const res = await api.get<GlobalBlacklistResponse>('/blacklist/rules/')
|
||||
return res.data
|
||||
}
|
||||
@@ -20,6 +25,10 @@ export async function getGlobalBlacklist(): Promise<GlobalBlacklistResponse> {
|
||||
* Update global blacklist rules (full replace)
|
||||
*/
|
||||
export async function updateGlobalBlacklist(data: UpdateGlobalBlacklistRequest): Promise<GlobalBlacklistResponse> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
return updateMockGlobalBlacklist(data)
|
||||
}
|
||||
const res = await api.put<GlobalBlacklistResponse>('/blacklist/rules/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
BatchCreateTargetsRequest,
|
||||
BatchCreateTargetsResponse,
|
||||
} from '@/types/target.types'
|
||||
import { USE_MOCK, mockDelay, getMockTargets, getMockTargetById } from '@/mock'
|
||||
import { USE_MOCK, mockDelay, getMockTargets, getMockTargetById, getMockTargetBlacklist, updateMockTargetBlacklist } from '@/mock'
|
||||
|
||||
/**
|
||||
* Get all targets list (paginated)
|
||||
@@ -163,6 +163,10 @@ export async function getTargetEndpoints(
|
||||
* Get target's blacklist rules
|
||||
*/
|
||||
export async function getTargetBlacklist(id: number): Promise<{ patterns: string[] }> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
return getMockTargetBlacklist(id)
|
||||
}
|
||||
const response = await api.get<{ patterns: string[] }>(`/targets/${id}/blacklist/`)
|
||||
return response.data
|
||||
}
|
||||
@@ -174,6 +178,11 @@ export async function updateTargetBlacklist(
|
||||
id: number,
|
||||
patterns: string[]
|
||||
): Promise<{ count: number }> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
const result = updateMockTargetBlacklist(id, { patterns })
|
||||
return { count: result.patterns.length }
|
||||
}
|
||||
const response = await api.put<{ count: number }>(`/targets/${id}/blacklist/`, { patterns })
|
||||
return response.data
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user