Compare commits

..

10 Commits

Author SHA1 Message Date
yyhuni
d6d5338acb 增加资产删除功能 2026-01-07 09:29:31 +08:00
yyhuni
c521bdb511 重构:回退逻辑 2026-01-07 08:45:27 +08:00
yyhuni
abf2d95f6f feat(targets): increase max batch size for target creation from 1000 to 5000
- Update MAX_BATCH_SIZE constant in BatchCreateTargetSerializer from 1000 to 5000
- Increase batch creation limit to support larger bulk operations
- Update documentation comment to reflect new limit
- Allows users to create up to 5000 targets in a single batch operation
2026-01-06 20:39:31 +08:00
github-actions[bot]
ab58cf0d85 chore: bump version to v1.4.0 2026-01-06 09:31:29 +00:00
yyhuni
fb0111adf2 Merge branch 'dev' 2026-01-06 17:27:35 +08:00
yyhuni
161ee9a2b1 Merge branch 'dev' 2026-01-06 17:27:16 +08:00
yyhuni
0cf75585d5 docs: 添加黑名单过滤功能说明到 README 2026-01-06 17:25:31 +08:00
yyhuni
1d8d5f51d9 feat(blacklist): add mock data and service integration for blacklist management
- Create new blacklist mock data module with global and target-specific patterns
- Add mock functions for getting and updating global blacklist rules
- Add mock functions for getting and updating target-specific blacklist rules
- Integrate mock blacklist endpoints into global-blacklist.service.ts
- Integrate mock blacklist endpoints into target.service.ts
- Export blacklist mock functions from main mock index
- Enable testing of blacklist management UI without backend API
2026-01-06 17:08:51 +08:00
github-actions[bot]
3f8de07c8c chore: bump version to v1.4.0-dev 2026-01-06 09:02:31 +00:00
yyhuni
6ff86e14ec Update README.md 2026-01-06 09:59:55 +08:00
29 changed files with 870 additions and 173 deletions

View File

@@ -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 应用技术栈

View File

@@ -1 +1 @@
v1.3.16-dev
v1.4.0

View File

@@ -12,16 +12,19 @@ from .views import (
AssetStatisticsViewSet,
AssetSearchView,
AssetSearchExportView,
EndpointViewSet,
HostPortMappingViewSet,
)
# 创建 DRF 路由器
router = DefaultRouter()
# 注册 ViewSet
# 注意IPAddress 模型已被重构为 HostPortMapping相关路由已移除
router.register(r'subdomains', SubdomainViewSet, basename='subdomain')
router.register(r'websites', WebSiteViewSet, basename='website')
router.register(r'directories', DirectoryViewSet, basename='directory')
router.register(r'endpoints', EndpointViewSet, basename='endpoint')
router.register(r'ip-addresses', HostPortMappingViewSet, basename='ip-address')
router.register(r'vulnerabilities', VulnerabilityViewSet, basename='vulnerability')
router.register(r'statistics', AssetStatisticsViewSet, basename='asset-statistics')

View File

@@ -260,6 +260,35 @@ class SubdomainViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除子域名
POST /api/assets/subdomains/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import Subdomain
deleted_count, _ = Subdomain.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除子域名失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete subdomains',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class WebSiteViewSet(viewsets.ModelViewSet):
"""站点管理 ViewSet
@@ -393,6 +422,35 @@ class WebSiteViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除网站
POST /api/assets/websites/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import WebSite
deleted_count, _ = WebSite.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除网站失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete websites',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class DirectoryViewSet(viewsets.ModelViewSet):
"""目录管理 ViewSet
@@ -521,6 +579,35 @@ class DirectoryViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除目录
POST /api/assets/directories/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import Directory
deleted_count, _ = Directory.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除目录失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete directories',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class EndpointViewSet(viewsets.ModelViewSet):
"""端点管理 ViewSet
@@ -655,6 +742,35 @@ class EndpointViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除端点
POST /api/assets/endpoints/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import Endpoint
deleted_count, _ = Endpoint.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除端点失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete endpoints',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class HostPortMappingViewSet(viewsets.ModelViewSet):
"""主机端口映射管理 ViewSetIP 地址聚合视图)
@@ -728,6 +844,38 @@ class HostPortMappingViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除 IP 地址映射
POST /api/assets/ip-addresses/bulk-delete/
请求体: {"ips": ["192.168.1.1", "10.0.0.1"]}
响应: {"deletedCount": 3}
注意:由于 IP 地址是聚合显示的,删除时传入 IP 列表,
会删除该 IP 下的所有 host:port 映射记录
"""
ips = request.data.get('ips', [])
if not ips or not isinstance(ips, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ips is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import HostPortMapping
deleted_count, _ = HostPortMapping.objects.filter(ip__in=ips).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除 IP 地址映射失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete ip addresses',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class VulnerabilityViewSet(viewsets.ModelViewSet):
"""漏洞资产管理 ViewSet只读

View File

@@ -17,7 +17,12 @@ from .scan_state_service import ScanStateService
from .scan_control_service import ScanControlService
from .scan_stats_service import ScanStatsService
from .scheduled_scan_service import ScheduledScanService
from .target_export_service import TargetExportService
from .target_export_service import (
TargetExportService,
create_export_service,
export_urls_with_fallback,
DataSource,
)
__all__ = [
'ScanService', # 主入口(向后兼容)
@@ -27,5 +32,8 @@ __all__ = [
'ScanStatsService',
'ScheduledScanService',
'TargetExportService', # 目标导出服务
'create_export_service',
'export_urls_with_fallback',
'DataSource',
]

View File

@@ -2,7 +2,9 @@
目标导出服务
提供统一的目标提取和文件导出功能,支持:
- URL 导出(流式写入 + 默认值回退)
- URL 导出(纯导出,不做隐式回退)
- 默认 URL 生成(独立方法)
- 带回退链的 URL 导出(用例层编排)
- 域名/IP 导出(用于端口扫描)
- 黑名单过滤集成
"""
@@ -10,7 +12,7 @@
import ipaddress
import logging
from pathlib import Path
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, Callable
from django.db.models import QuerySet
@@ -19,6 +21,14 @@ from apps.common.utils import BlacklistFilter
logger = logging.getLogger(__name__)
class DataSource:
"""数据源类型常量"""
ENDPOINT = "endpoint"
WEBSITE = "website"
HOST_PORT = "host_port"
DEFAULT = "default"
def create_export_service(target_id: int) -> 'TargetExportService':
"""
工厂函数:创建带黑名单过滤的导出服务
@@ -36,21 +46,129 @@ def create_export_service(target_id: int) -> 'TargetExportService':
return TargetExportService(blacklist_filter=blacklist_filter)
def export_urls_with_fallback(
target_id: int,
output_file: str,
sources: List[str],
batch_size: int = 1000
) -> Dict[str, Any]:
"""
带回退链的 URL 导出用例函数
按 sources 顺序尝试每个数据源,直到有数据返回。
回退逻辑:
1. 遍历 sources 列表
2. 对每个 source 构建 queryset 并调用 export_urls()
3. 如果 total_count > 0返回
4. 如果 queryset_count > 0 但 total_count == 0全被黑名单过滤不回退
5. 如果 source == "default",调用 generate_default_urls()
Args:
target_id: 目标 ID
output_file: 输出文件路径
sources: 数据源优先级列表,如 ["endpoint", "website", "default"]
batch_size: 批次大小
Returns:
dict: {
'success': bool,
'output_file': str,
'total_count': int,
'source': str, # 实际使用的数据源
'tried_sources': List[str], # 尝试过的数据源
}
"""
from apps.asset.models import Endpoint, WebSite
export_service = create_export_service(target_id)
tried_sources = []
for source in sources:
tried_sources.append(source)
if source == DataSource.DEFAULT:
# 默认 URL 生成
result = export_service.generate_default_urls(target_id, output_file)
return {
'success': result['success'],
'output_file': result['output_file'],
'total_count': result['total_count'],
'source': DataSource.DEFAULT,
'tried_sources': tried_sources,
}
# 构建对应数据源的 queryset
if source == DataSource.ENDPOINT:
queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
elif source == DataSource.WEBSITE:
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
else:
logger.warning("未知的数据源类型: %s,跳过", source)
continue
result = export_service.export_urls(
target_id=target_id,
output_path=output_file,
queryset=queryset,
batch_size=batch_size
)
# 有数据写入,返回
if result['total_count'] > 0:
logger.info("%s 导出 %d 条 URL", source, result['total_count'])
return {
'success': result['success'],
'output_file': result['output_file'],
'total_count': result['total_count'],
'source': source,
'tried_sources': tried_sources,
}
# 数据存在但全被黑名单过滤,不回退
if result['queryset_count'] > 0:
logger.info(
"%s%d 条数据但全被黑名单过滤filtered=%d),不回退",
source, result['queryset_count'], result['filtered_count']
)
return {
'success': result['success'],
'output_file': result['output_file'],
'total_count': 0,
'source': source,
'tried_sources': tried_sources,
}
# 数据源为空,继续尝试下一个
logger.info("%s 为空,尝试下一个数据源", source)
# 所有数据源都为空
logger.warning("所有数据源都为空,无法导出 URL")
return {
'success': True,
'output_file': output_file,
'total_count': 0,
'source': 'none',
'tried_sources': tried_sources,
}
class TargetExportService:
"""
目标导出服务 - 提供统一的目标提取和文件导出功能
使用方式:
from apps.common.services import BlacklistService
from apps.common.utils import BlacklistFilter
# 方式 1使用用例函数推荐
from apps.scan.services.target_export_service import export_urls_with_fallback, DataSource
# 获取规则并创建过滤器
blacklist_service = BlacklistService()
rules = blacklist_service.get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
result = export_urls_with_fallback(
target_id=1,
output_file='/path/to/output.txt',
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT]
)
# 使用导出服务
export_service = TargetExportService(blacklist_filter=blacklist_filter)
# 方式 2直接使用 Service纯导出不带回退
export_service = create_export_service(target_id)
result = export_service.export_urls(target_id, output_path, queryset)
"""
@@ -72,16 +190,14 @@ class TargetExportService:
batch_size: int = 1000
) -> Dict[str, Any]:
"""
统一 URL 导出函数
URL 导出函数 - 只负责将 queryset 数据写入文件
自动判断数据库有无数据:
- 有数据:流式写入数据库数据到文件
- 无数据:调用默认值生成器生成 URL
不做任何隐式回退或默认 URL 生成。
Args:
target_id: 目标 ID
output_path: 输出文件路径
queryset: 数据源 queryset Task 层构建,应为 values_list flat=True
queryset: 数据源 queryset调用方构建,应为 values_list flat=True
url_field: URL 字段名(用于黑名单过滤)
batch_size: 批次大小
@@ -89,7 +205,9 @@ class TargetExportService:
dict: {
'success': bool,
'output_file': str,
'total_count': int
'total_count': int, # 实际写入数量
'queryset_count': int, # 原始数据数量(迭代计数)
'filtered_count': int, # 被黑名单过滤的数量
}
Raises:
@@ -102,9 +220,12 @@ class TargetExportService:
total_count = 0
filtered_count = 0
queryset_count = 0
try:
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
for url in queryset.iterator(chunk_size=batch_size):
queryset_count += 1
if url:
# 黑名单过滤
if self.blacklist_filter and not self.blacklist_filter.is_allowed(url):
@@ -122,25 +243,26 @@ class TargetExportService:
if filtered_count > 0:
logger.info("黑名单过滤: 过滤 %d 个 URL", filtered_count)
# 默认值回退模式
if total_count == 0:
total_count = self._generate_default_urls(target_id, output_file)
logger.info("✓ URL 导出完成 - 数量: %d, 文件: %s", total_count, output_path)
logger.info(
"✓ URL 导出完成 - 写入: %d, 原始: %d, 过滤: %d, 文件: %s",
total_count, queryset_count, filtered_count, output_path
)
return {
'success': True,
'output_file': str(output_file),
'total_count': total_count
'total_count': total_count,
'queryset_count': queryset_count,
'filtered_count': filtered_count,
}
def _generate_default_urls(
def generate_default_urls(
self,
target_id: int,
output_path: Path
) -> int:
output_path: str
) -> Dict[str, Any]:
"""
默认值生成器(内部函数)
默认 URL 生成器
根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
@@ -153,26 +275,37 @@ class TargetExportService:
output_path: 输出文件路径
Returns:
int: 写入的 URL 总数
dict: {
'success': bool,
'output_file': str,
'total_count': int,
}
"""
from apps.targets.services import TargetService
from apps.targets.models import Target
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
target_service = TargetService()
target = target_service.get_target(target_id)
if not target:
logger.warning("Target ID %d 不存在,无法生成默认 URL", target_id)
return 0
return {
'success': True,
'output_file': str(output_file),
'total_count': 0,
}
target_name = target.name
target_type = target.type
logger.info("懒加载模式Target 类型=%s, 名称=%s", target_type, target_name)
logger.info("生成默认 URLTarget 类型=%s, 名称=%s", target_type, target_name)
total_urls = 0
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
if target_type == Target.TargetType.DOMAIN:
urls = [f"http://{target_name}", f"https://{target_name}"]
for url in urls:
@@ -221,8 +354,13 @@ class TargetExportService:
else:
logger.warning("不支持的 Target 类型: %s", target_type)
logger.info("懒加载生成默认 URL - 数量: %d", total_urls)
return total_urls
logger.info("✓ 默认 URL 生成完成 - 数量: %d", total_urls)
return {
'success': True,
'output_file': str(output_file),
'total_count': total_urls,
}
def _should_write_url(self, url: str) -> bool:
"""检查 URL 是否应该写入(通过黑名单过滤)"""

View File

@@ -1,15 +1,16 @@
"""
导出站点 URL 到 TXT 文件的 Task
使用 TargetExportService 统一处理导出逻辑和默认值回退
数据源: WebSite.url
使用 export_urls_with_fallback 用例函数处理回退链逻辑
数据源: WebSite.url → Default
"""
import logging
from prefect import task
from apps.asset.models import WebSite
from apps.scan.services import TargetExportService
from apps.scan.services.target_export_service import create_export_service
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
@@ -23,13 +24,9 @@ def export_sites_task(
"""
导出目标下的所有站点 URL 到 TXT 文件
数据源: WebSite.url
懒加载模式:
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
数据源优先级(回退链):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID
@@ -47,25 +44,21 @@ def export_sites_task(
ValueError: 参数错误
IOError: 文件写入失败
"""
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
result = export_urls_with_fallback(
target_id=target_id,
output_path=output_file,
queryset=queryset,
batch_size=batch_size
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"站点 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 保持返回值格式不变(向后兼容)
return {
'success': result['success'],
'output_file': result['output_file'],
'total_count': result['total_count']
'total_count': result['total_count'],
}

View File

@@ -2,15 +2,17 @@
导出 URL 任务
用于指纹识别前导出目标下的 URL 到文件
使用 TargetExportService 统一处理导出逻辑和默认值回退
使用 export_urls_with_fallback 用例函数处理回退链逻辑
"""
import logging
from prefect import task
from apps.asset.models import WebSite
from apps.scan.services.target_export_service import create_export_service
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
@@ -19,46 +21,40 @@ logger = logging.getLogger(__name__)
def export_urls_for_fingerprint_task(
target_id: int,
output_file: str,
source: str = 'website',
source: str = 'website', # 保留参数,兼容旧调用(实际值由回退链决定)
batch_size: int = 1000
) -> dict:
"""
导出目标下的 URL 到文件(用于指纹识别)
数据源: WebSite.url
懒加载模式:
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
- URL: 直接使用目标 URL
数据源优先级(回退链):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID
output_file: 输出文件路径
source: 数据源类型(保留参数,兼容旧调用)
source: 数据源类型(保留参数,兼容旧调用,实际值由回退链决定
batch_size: 批量读取大小
Returns:
dict: {'output_file': str, 'total_count': int, 'source': str}
"""
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
result = export_urls_with_fallback(
target_id=target_id,
output_path=output_file,
queryset=queryset,
batch_size=batch_size
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
# 保持返回值格式不变(向后兼容)
logger.info(
"指纹识别 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 返回实际使用的数据源(不再固定为 "website"
return {
'output_file': result['output_file'],
'total_count': result['total_count'],
'source': source
'source': result['source'],
}

View File

@@ -2,7 +2,7 @@
导出站点URL到文件的Task
直接使用 HostPortMapping 表查询 host+port 组合拼接成URL格式写入文件
使用 TargetExportService 处理默认值回退逻辑
使用 TargetExportService.generate_default_urls() 处理默认值回退逻辑
特殊逻辑:
- 80 端口:只生成 HTTP URL省略端口号
@@ -46,18 +46,15 @@ def export_site_urls_task(
"""
导出目标下的所有站点URL到文件基于 HostPortMapping 表)
数据源: HostPortMapping (host + port)
数据源: HostPortMapping (host + port) → Default
特殊逻辑:
- 80 端口:只生成 HTTP URL省略端口号
- 443 端口:只生成 HTTPS URL省略端口号
- 其他端口:生成 HTTP 和 HTTPS 两个URL带端口号
懒加载模式
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
回退逻辑
- 如果 HostPortMapping 为空,使用 generate_default_urls() 生成默认 URL
Args:
target_id: 目标ID
@@ -69,7 +66,8 @@ def export_site_urls_task(
'success': bool,
'output_file': str,
'total_urls': int,
'association_count': int # 主机端口关联数量
'association_count': int, # 主机端口关联数量
'source': str, # 数据来源: "host_port" | "default"
}
Raises:
@@ -94,6 +92,7 @@ def export_site_urls_task(
total_urls = 0
association_count = 0
filtered_count = 0
# 流式写入文件(特殊端口逻辑)
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
@@ -104,6 +103,7 @@ def export_site_urls_task(
# 先校验 host通过了再生成 URL
if not blacklist_filter.is_allowed(host):
filtered_count += 1
continue
# 根据端口号生成URL
@@ -114,19 +114,40 @@ def export_site_urls_task(
if association_count % 1000 == 0:
logger.info("已处理 %d 条关联,生成 %d 个URL...", association_count, total_urls)
if filtered_count > 0:
logger.info("黑名单过滤: 过滤 %d 条关联", filtered_count)
logger.info(
"✓ 站点URL导出完成 - 关联数: %d, 总URL数: %d, 文件: %s",
association_count, total_urls, str(output_path)
)
# 默认值回退模式:使用工厂函数创建导出服务
# 判断数据来源
source = "host_port"
# 数据存在但全被过滤,不回退
if association_count > 0 and total_urls == 0:
logger.info("HostPortMapping 有 %d 条数据,但全被黑名单过滤,不回退", association_count)
return {
'success': True,
'output_file': str(output_path),
'total_urls': 0,
'association_count': association_count,
'source': source,
}
# 数据源为空,回退到默认 URL 生成
if total_urls == 0:
logger.info("HostPortMapping 为空,使用默认 URL 生成")
export_service = create_export_service(target_id)
total_urls = export_service._generate_default_urls(target_id, output_path)
result = export_service.generate_default_urls(target_id, str(output_path))
total_urls = result['total_count']
source = "default"
return {
'success': True,
'output_file': str(output_path),
'total_urls': total_urls,
'association_count': association_count
'association_count': association_count,
'source': source,
}

View File

@@ -1,16 +1,17 @@
"""
导出站点 URL 列表任务
使用 TargetExportService 统一处理导出逻辑和默认值回退
数据源: WebSite.url用于 katana 等爬虫工具)
使用 export_urls_with_fallback 用例函数处理回退链逻辑
数据源: WebSite.url → Default(用于 katana 等爬虫工具)
"""
import logging
from prefect import task
from typing import Optional
from apps.asset.models import WebSite
from apps.scan.services.target_export_service import create_export_service
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
@@ -29,13 +30,9 @@ def export_sites_task(
"""
导出站点 URL 列表到文件(用于 katana 等爬虫工具)
数据源: WebSite.url
懒加载模式:
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
数据源优先级(回退链):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
output_file: 输出文件路径
@@ -53,17 +50,16 @@ def export_sites_task(
ValueError: 参数错误
RuntimeError: 执行失败
"""
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
result = export_urls_with_fallback(
target_id=target_id,
output_path=output_file,
queryset=queryset,
batch_size=batch_size
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"站点 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 保持返回值格式不变(向后兼容)

View File

@@ -1,6 +1,6 @@
"""导出 Endpoint URL 到文件的 Task
使用 TargetExportService 统一处理导出逻辑和默认值回退
使用 export_urls_with_fallback 用例函数处理回退链逻辑
数据源优先级(回退链):
1. Endpoint.url - 最精细的 URL含路径、参数等
@@ -9,13 +9,14 @@
"""
import logging
from pathlib import Path
from typing import Dict
from prefect import task
from apps.asset.models import Endpoint, WebSite
from apps.scan.services.target_export_service import create_export_service
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
@@ -43,55 +44,24 @@ def export_endpoints_task(
"success": bool,
"output_file": str,
"total_count": int,
"source": str, # 数据来源: "endpoint" | "website" | "default"
"source": str, # 数据来源: "endpoint" | "website" | "default" | "none"
}
"""
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(
result = export_urls_with_fallback(
target_id=target_id,
output_path=output_file,
queryset=endpoint_queryset,
batch_size=batch_size
output_file=output_file,
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT],
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
logger.info(
"URL 导出完成 - source=%s, count=%d, tried=%s",
result['source'], result['total_count'], result['tried_sources']
)
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 也为空,生成默认 URLexport_urls 内部已处理)
logger.info("WebSite 表也为空,使用默认 URL 生成")
return {
"success": True,
"success": result['success'],
"output_file": result['output_file'],
"total_count": result['total_count'],
"source": "default",
"source": result['source'],
}

View File

@@ -182,12 +182,12 @@ class BatchCreateTargetSerializer(serializers.Serializer):
批量创建目标的序列化器
安全限制:
- 最多支持 1000 个目标的批量创建
- 最多支持 5000 个目标的批量创建
- 防止恶意用户提交大量数据导致服务器过载
"""
# 批量创建的最大数量限制
MAX_BATCH_SIZE = 1000
MAX_BATCH_SIZE = 5000
# 目标列表
targets = serializers.ListField(

View File

@@ -11,6 +11,7 @@ import { useTargetDirectories, useScanDirectories } from "@/hooks/use-directorie
import { useTarget } from "@/hooks/use-targets"
import { DirectoryService } from "@/services/directory.service"
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { TargetType } from "@/lib/url-validator"
import type { Directory } from "@/types/directory.types"
@@ -29,6 +30,8 @@ export function DirectoriesView({
})
const [selectedDirectories, setSelectedDirectories] = useState<Directory[]>([])
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [filterQuery, setFilterQuery] = useState("")
const [isSearching, setIsSearching] = useState(false)
@@ -240,6 +243,26 @@ export function DirectoriesView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedDirectories.length === 0) return
setIsDeleting(true)
try {
const ids = selectedDirectories.map(d => d.id)
const result = await DirectoryService.bulkDelete(ids)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedDirectories([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete directories", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
@@ -280,6 +303,7 @@ export function DirectoriesView({
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
/>
@@ -295,6 +319,17 @@ export function DirectoriesView({
onSuccess={() => refetch()}
/>
)}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedDirectories.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)
}

View File

@@ -36,6 +36,7 @@ interface EndpointsDataTableProps<TData extends { id: number | string }, TValue>
onAddNew?: () => void
addButtonText?: string
onSelectionChange?: (selectedRows: TData[]) => void
onBulkDelete?: () => void
pagination?: { pageIndex: number; pageSize: number }
onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void
totalCount?: number
@@ -54,6 +55,7 @@ export function EndpointsDataTable<TData extends { id: number | string }, TValue
onAddNew,
addButtonText = "Add",
onSelectionChange,
onBulkDelete,
pagination: externalPagination,
onPaginationChange,
totalCount,
@@ -135,7 +137,8 @@ export function EndpointsDataTable<TData extends { id: number | string }, TValue
// Selection
onSelectionChange={onSelectionChange}
// Bulk operations
showBulkDelete={false}
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
onAddNew={onAddNew}
addButtonLabel={addButtonText}
// Bulk add button

View File

@@ -10,6 +10,7 @@ import { createEndpointColumns } from "./endpoints-columns"
import { LoadingSpinner } from "@/components/loading-spinner"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { TargetType } from "@/lib/url-validator"
import {
@@ -41,6 +42,8 @@ export function EndpointsDetailView({
const [endpointToDelete, setEndpointToDelete] = useState<Endpoint | null>(null)
const [selectedEndpoints, setSelectedEndpoints] = useState<Endpoint[]>([])
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Pagination state management
const [pagination, setPagination] = useState({
@@ -280,6 +283,26 @@ export function EndpointsDetailView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedEndpoints.length === 0) return
setIsDeleting(true)
try {
const ids = selectedEndpoints.map(e => e.id)
const result = await EndpointService.bulkDelete(ids)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedEndpoints([])
setBulkDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete endpoints", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
// Error state
if (error) {
return (
@@ -327,6 +350,7 @@ export function EndpointsDetailView({
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setBulkDeleteDialogOpen(true) : undefined}
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
/>
@@ -343,7 +367,18 @@ export function EndpointsDetailView({
/>
)}
{/* Delete confirmation dialog */}
{/* Bulk delete confirmation dialog */}
<ConfirmDialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
title={tConfirm("deleteTitle")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedEndpoints.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
{/* Single delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -8,6 +8,7 @@ import { createIPAddressColumns } from "./ip-addresses-columns"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import { Button } from "@/components/ui/button"
import { useTargetIPAddresses, useScanIPAddresses } from "@/hooks/use-ip-addresses"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { IPAddress } from "@/types/ip-address.types"
import { IPAddressService } from "@/services/ip-address.service"
@@ -26,6 +27,8 @@ export function IPAddressesView({
})
const [selectedIPAddresses, setSelectedIPAddresses] = useState<IPAddress[]>([])
const [filterQuery, setFilterQuery] = useState("")
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Internationalization
const tColumns = useTranslations("columns")
@@ -215,6 +218,27 @@ export function IPAddressesView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedIPAddresses.length === 0) return
setIsDeleting(true)
try {
// IP addresses are aggregated, pass IP strings instead of IDs
const ips = selectedIPAddresses.map(ip => ip.ip)
const result = await IPAddressService.bulkDelete(ips)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedIPAddresses([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete IP addresses", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
@@ -253,6 +277,18 @@ export function IPAddressesView({
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
/>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedIPAddresses.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)

View File

@@ -14,8 +14,10 @@ import { createSubdomainColumns } from "./subdomains-columns"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import { SubdomainService } from "@/services/subdomain.service"
import { BulkAddSubdomainsDialog } from "./bulk-add-subdomains-dialog"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { Subdomain } from "@/types/subdomain.types"
import { toast } from "sonner"
/**
* Subdomain detail view component
@@ -31,11 +33,14 @@ export function SubdomainsDetailView({
scanId?: number
}) {
const [selectedSubdomains, setSelectedSubdomains] = useState<Subdomain[]>([])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Internationalization
const tColumns = useTranslations("columns")
const tCommon = useTranslations("common")
const tSubdomains = useTranslations("subdomains")
const tToast = useTranslations("toast")
const locale = useLocale()
// Build translation object
@@ -215,6 +220,26 @@ export function SubdomainsDetailView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedSubdomains.length === 0) return
setIsDeleting(true)
try {
const ids = selectedSubdomains.map(s => s.id)
const result = await SubdomainService.bulkDeleteSubdomains(ids)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedSubdomains([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete subdomains", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
// Create column definitions
const subdomainColumns = useMemo(
() =>
@@ -279,6 +304,7 @@ export function SubdomainsDetailView({
isSearching={isSearching}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
pagination={pagination}
setPagination={setPagination}
paginationInfo={{
@@ -301,6 +327,17 @@ export function SubdomainsDetailView({
onSuccess={() => refetch()}
/>
)}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedSubdomains.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
onConfirm: () => void | Promise<void>
loading?: boolean
variant?: "default" | "destructive"
confirmText?: string
cancelText?: string
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
onConfirm,
loading = false,
variant = "default",
confirmText,
cancelText,
}: ConfirmDialogProps) {
const t = useTranslations("common.actions")
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{cancelText || t("cancel")}
</Button>
<Button
variant={variant}
onClick={onConfirm}
disabled={loading}
>
{loading ? t("processing") : (confirmText || t("confirm"))}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -11,6 +11,7 @@ import { useTargetWebSites, useScanWebSites } from "@/hooks/use-websites"
import { useTarget } from "@/hooks/use-targets"
import { WebsiteService } from "@/services/website.service"
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { TargetType } from "@/lib/url-validator"
import type { WebSite } from "@/types/website.types"
@@ -29,6 +30,8 @@ export function WebSitesView({
})
const [selectedWebSites, setSelectedWebSites] = useState<WebSite[]>([])
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [filterQuery, setFilterQuery] = useState("")
const [isSearching, setIsSearching] = useState(false)
@@ -251,6 +254,26 @@ export function WebSitesView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedWebSites.length === 0) return
setIsDeleting(true)
try {
const ids = selectedWebSites.map(w => w.id)
const result = await WebsiteService.bulkDelete(ids)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedWebSites([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete websites", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
@@ -291,6 +314,7 @@ export function WebSitesView({
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
/>
@@ -306,6 +330,17 @@ export function WebSitesView({
onSuccess={() => refetch()}
/>
)}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedWebSites.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)
}

View File

@@ -173,7 +173,10 @@
"selectAll": "Select all",
"selectRow": "Select row",
"website": "Website",
"description": "Description"
"description": "Description",
"processing": "Processing...",
"confirmDelete": "Confirm Delete",
"deleteConfirmMessage": "Are you sure you want to delete {count} selected items? This action cannot be undone."
},
"yamlEditor": {
"syntaxError": "Syntax Error",
@@ -1456,8 +1459,9 @@
"copied": "Copied",
"copyFailed": "Copy failed",
"downloadFailed": "Download failed",
"deletedScanRecord": "Deleted scan record: {name}",
"deleteSuccess": "Deleted successfully: {count} items",
"deleteFailed": "Delete failed, please retry",
"deletedScanRecord": "Deleted scan record: {name}",
"stoppedScan": "Stopped scan task: {name}",
"stopFailed": "Stop failed, please retry",
"bulkDeleteSuccess": "Deleted {count} scan records",

View File

@@ -173,7 +173,10 @@
"selectAll": "全选",
"selectRow": "选择行",
"website": "官网",
"description": "描述"
"description": "描述",
"processing": "处理中...",
"confirmDelete": "确认删除",
"deleteConfirmMessage": "确定要删除选中的 {count} 条记录吗?此操作不可撤销。"
},
"yamlEditor": {
"syntaxError": "语法错误",
@@ -1456,8 +1459,9 @@
"copied": "已复制",
"copyFailed": "复制失败",
"downloadFailed": "下载失败",
"deletedScanRecord": "删除扫描记录: {name}",
"deleteSuccess": "删除成功:{count} 条",
"deleteFailed": "删除失败,请重试",
"deletedScanRecord": "已删除扫描记录: {name}",
"stoppedScan": "已停止扫描任务: {name}",
"stopFailed": "停止失败,请重试",
"bulkDeleteSuccess": "已删除 {count} 个扫描记录",

View 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],
}
}

View File

@@ -182,3 +182,11 @@ export {
getMockNotificationSettings,
updateMockNotificationSettings,
} from './data/notification-settings'
// Blacklist
export {
getMockGlobalBlacklist,
updateMockGlobalBlacklist,
getMockTargetBlacklist,
updateMockTargetBlacklist,
} from './data/blacklist'

View File

@@ -6,8 +6,25 @@ export interface BulkCreateDirectoriesResponse {
createdCount: number
}
// Bulk delete response type
export interface BulkDeleteResponse {
deletedCount: number
}
/** Directory related API service */
export class DirectoryService {
/**
* Bulk delete directories
* POST /api/assets/directories/bulk-delete/
*/
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/directories/bulk-delete/`,
{ ids }
)
return response.data
}
/**
* Bulk create directories (bind to target)
* POST /api/targets/{target_id}/directories/bulk-create/

View File

@@ -16,8 +16,25 @@ export interface BulkCreateEndpointsResponse {
createdCount: number
}
// Bulk delete response type
export interface BulkDeleteResponse {
deletedCount: number
}
export class EndpointService {
/**
* Bulk delete endpoints
* POST /api/assets/endpoints/bulk-delete/
*/
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/endpoints/bulk-delete/`,
{ ids }
)
return response.data
}
/**
* Bulk create endpoints (bind to target)
* POST /api/targets/{target_id}/endpoints/bulk-create/

View File

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

View File

@@ -1,7 +1,25 @@
import { api } from "@/lib/api-client"
import type { GetIPAddressesParams, GetIPAddressesResponse } from "@/types/ip-address.types"
// Bulk delete response type
export interface BulkDeleteResponse {
deletedCount: number
}
export class IPAddressService {
/**
* Bulk delete IP addresses
* POST /api/assets/ip-addresses/bulk-delete/
* Note: IP addresses are aggregated, so we pass IP strings instead of IDs
*/
static async bulkDelete(ips: string[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/ip-addresses/bulk-delete/`,
{ ips }
)
return response.data
}
static async getTargetIPAddresses(
targetId: number,
params?: GetIPAddressesParams

View File

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

View File

@@ -6,11 +6,27 @@ export interface BulkCreateWebsitesResponse {
createdCount: number
}
// Bulk delete response type
export interface BulkDeleteResponse {
deletedCount: number
}
/**
* Website related API service
* All frontend website interface calls should be centralized here
*/
export class WebsiteService {
/**
* Bulk delete websites
* POST /api/assets/websites/bulk-delete/
*/
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/websites/bulk-delete/`,
{ ids }
)
return response.data
}
/**
* Bulk create websites (bind to target)
* POST /api/targets/{target_id}/websites/bulk-create/