Compare commits

..

10 Commits

Author SHA1 Message Date
yyhuni
e0abb3ce7b Merge branch 'dev' 2026-01-04 18:57:49 +08:00
yyhuni
d418baaf79 feat(mock,scan): add comprehensive mock data and improve system load management
- Add mock data files for directories, fingerprints, IP addresses, notification settings, nuclei templates, search, system logs, tools, and wordlists
- Update mock index to export new mock data modules
- Increase SCAN_LOAD_CHECK_INTERVAL from 30 to 180 seconds for better system stability
- Improve load check logging message to clarify OOM prevention strategy
- Enhance mock data infrastructure to support frontend development and testing
2026-01-04 18:52:08 +08:00
github-actions[bot]
f8da408580 chore: bump version to v1.3.13-dev 2026-01-04 10:24:10 +00:00
yyhuni
7cd4354d8f feat(scan,asset): add scan logging system and improve search view architecture
- Add user_logger utility for structured scan operation logging
- Create scan log views and API endpoints for retrieving scan execution logs
- Add scan-log-list component and use-scan-logs hook for frontend log display
- Refactor asset search views to remove ArrayField support from pg_ivm IMMV
- Update search_service.py to JOIN original tables for array field retrieval
- Add system architecture requirements (AMD64/ARM64) to README
- Update scan flow handlers to integrate logging system
- Enhance scan progress dialog with log viewer integration
- Add ANSI log viewer component for formatted log display
- Update scan service API to support log retrieval endpoints
- Migrate database schema to support new logging infrastructure
- Add internationalization strings for scan logs (en/zh)
This change improves observability of scan operations and resolves pg_ivm limitations with ArrayField types by fetching array data from original tables via JOIN operations.
2026-01-04 18:19:45 +08:00
yyhuni
6bf35a760f chore(docker): configure Prefect home directory in worker image
- Add PREFECT_HOME environment variable pointing to /app/.prefect
- Create Prefect configuration directory to prevent home directory warnings
- Update step numbering in Dockerfile comments for clarity
- Ensures Prefect can properly initialize configuration without relying on user home directory
2026-01-04 10:39:11 +08:00
github-actions[bot]
be9ecadffb chore: bump version to v1.3.12-dev 2026-01-04 01:05:00 +00:00
yyhuni
adb53c9f85 feat(asset,scan): add configurable statement timeout and improve CSV export
- Add statement_timeout_ms parameter to search_service count() and stream_search() methods for long-running exports
- Replace server-side cursors with OFFSET/LIMIT batching for better Django compatibility
- Introduce create_csv_export_response() utility function to standardize CSV export handling
- Add engine-preset-selector and scan-config-editor components for enhanced scan configuration UI
- Update YAML editor component with improved styling and functionality
- Add i18n translations for new scan configuration features in English and Chinese
- Refactor CSV export endpoints to use new utility function instead of manual StreamingHttpResponse
- Remove unused uuid import from search_service.py
- Update nginx configuration for improved performance
- Enhance search service with configurable timeout support for large dataset exports
2026-01-04 08:58:31 +08:00
yyhuni
7b7bbed634 Update README.md 2026-01-03 22:15:35 +08:00
github-actions[bot]
8dd3f0536e chore: bump version to v1.3.11-dev 2026-01-03 11:54:31 +00:00
github-actions[bot]
08372588a4 chore: bump version to v1.2.15 2026-01-01 15:44:15 +00:00
56 changed files with 3762 additions and 990 deletions

View File

@@ -198,6 +198,7 @@ url="/api/v1" && status!="404"
### 环境要求
- **操作系统**: Ubuntu 20.04+ / Debian 11+
- **系统架构**: AMD64 (x86_64) / ARM64 (aarch64)
- **硬件**: 2核 4G 内存起步20GB+ 磁盘空间
### 一键安装

View File

@@ -1 +1 @@
v1.3.10-dev
v1.3.13

View File

@@ -6,6 +6,18 @@
包含:
1. asset_search_view - Website 搜索视图
2. endpoint_search_view - Endpoint 搜索视图
重要限制:
⚠️ pg_ivm 不支持数组类型字段ArrayField因为其使用 anyarray 伪类型进行比较时,
PostgreSQL 无法确定空数组的元素类型,导致错误:
"cannot determine element type of \"anyarray\" argument"
因此,所有 ArrayField 字段tech, matched_gf_patterns 等)已从 IMMV 中移除,
搜索时通过 JOIN 原表获取。
如需添加新的数组字段,请:
1. 不要将其包含在 IMMV 视图中
2. 在搜索服务中通过 JOIN 原表获取
"""
from django.db import migrations
@@ -33,6 +45,8 @@ class Migration(migrations.Migration):
# ==================== Website IMMV ====================
# 2. 创建 asset_search_view IMMV
# ⚠️ 注意:不包含 w.tech 数组字段pg_ivm 不支持 ArrayField
# 数组字段通过 search_service.py 中 JOIN website 表获取
migrations.RunSQL(
sql="""
SELECT pgivm.create_immv('asset_search_view', $$
@@ -41,7 +55,6 @@ class Migration(migrations.Migration):
w.url,
w.host,
w.title,
w.tech,
w.status_code,
w.response_headers,
w.response_body,
@@ -85,10 +98,6 @@ class Migration(migrations.Migration):
CREATE INDEX IF NOT EXISTS asset_search_view_body_trgm_idx
ON asset_search_view USING gin (response_body gin_trgm_ops);
-- tech 数组索引
CREATE INDEX IF NOT EXISTS asset_search_view_tech_idx
ON asset_search_view USING gin (tech);
-- status_code 索引
CREATE INDEX IF NOT EXISTS asset_search_view_status_idx
ON asset_search_view (status_code);
@@ -104,7 +113,6 @@ class Migration(migrations.Migration):
DROP INDEX IF EXISTS asset_search_view_url_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_headers_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_body_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_tech_idx;
DROP INDEX IF EXISTS asset_search_view_status_idx;
DROP INDEX IF EXISTS asset_search_view_created_idx;
"""
@@ -113,6 +121,8 @@ class Migration(migrations.Migration):
# ==================== Endpoint IMMV ====================
# 4. 创建 endpoint_search_view IMMV
# ⚠️ 注意:不包含 e.tech 和 e.matched_gf_patterns 数组字段pg_ivm 不支持 ArrayField
# 数组字段通过 search_service.py 中 JOIN endpoint 表获取
migrations.RunSQL(
sql="""
SELECT pgivm.create_immv('endpoint_search_view', $$
@@ -121,7 +131,6 @@ class Migration(migrations.Migration):
e.url,
e.host,
e.title,
e.tech,
e.status_code,
e.response_headers,
e.response_body,
@@ -130,7 +139,6 @@ class Migration(migrations.Migration):
e.webserver,
e.location,
e.vhost,
e.matched_gf_patterns,
e.created_at,
e.target_id
FROM endpoint e
@@ -166,10 +174,6 @@ class Migration(migrations.Migration):
CREATE INDEX IF NOT EXISTS endpoint_search_view_body_trgm_idx
ON endpoint_search_view USING gin (response_body gin_trgm_ops);
-- tech 数组索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_tech_idx
ON endpoint_search_view USING gin (tech);
-- status_code 索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_status_idx
ON endpoint_search_view (status_code);
@@ -185,7 +189,6 @@ class Migration(migrations.Migration):
DROP INDEX IF EXISTS endpoint_search_view_url_trgm_idx;
DROP INDEX IF EXISTS endpoint_search_view_headers_trgm_idx;
DROP INDEX IF EXISTS endpoint_search_view_body_trgm_idx;
DROP INDEX IF EXISTS endpoint_search_view_tech_idx;
DROP INDEX IF EXISTS endpoint_search_view_status_idx;
DROP INDEX IF EXISTS endpoint_search_view_created_idx;
"""

View File

@@ -11,7 +11,6 @@
import logging
import re
import uuid
from typing import Optional, List, Dict, Any, Tuple, Literal, Iterator
from django.db import connection
@@ -38,46 +37,55 @@ VIEW_MAPPING = {
'endpoint': 'endpoint_search_view',
}
# 资产类型到原表名的映射(用于 JOIN 获取数组字段)
# ⚠️ 重要pg_ivm 不支持 ArrayField所有数组字段必须从原表 JOIN 获取
TABLE_MAPPING = {
'website': 'website',
'endpoint': 'endpoint',
}
# 有效的资产类型
VALID_ASSET_TYPES = {'website', 'endpoint'}
# Website 查询字段
# Website 查询字段v=视图t=原表)
# ⚠️ 注意t.tech 从原表获取,因为 pg_ivm 不支持 ArrayField
WEBSITE_SELECT_FIELDS = """
id,
url,
host,
title,
tech,
status_code,
response_headers,
response_body,
content_type,
content_length,
webserver,
location,
vhost,
created_at,
target_id
v.id,
v.url,
v.host,
v.title,
t.tech, -- ArrayField从 website 表 JOIN 获取
v.status_code,
v.response_headers,
v.response_body,
v.content_type,
v.content_length,
v.webserver,
v.location,
v.vhost,
v.created_at,
v.target_id
"""
# Endpoint 查询字段(包含 matched_gf_patterns
# Endpoint 查询字段
# ⚠️ 注意t.tech 和 t.matched_gf_patterns 从原表获取,因为 pg_ivm 不支持 ArrayField
ENDPOINT_SELECT_FIELDS = """
id,
url,
host,
title,
tech,
status_code,
response_headers,
response_body,
content_type,
content_length,
webserver,
location,
vhost,
matched_gf_patterns,
created_at,
target_id
v.id,
v.url,
v.host,
v.title,
t.tech, -- ArrayField从 endpoint 表 JOIN 获取
v.status_code,
v.response_headers,
v.response_body,
v.content_type,
v.content_length,
v.webserver,
v.location,
v.vhost,
t.matched_gf_patterns, -- ArrayField从 endpoint 表 JOIN 获取
v.created_at,
v.target_id
"""
@@ -120,8 +128,8 @@ class SearchQueryParser:
# 检查是否包含操作符语法,如果不包含则作为 host 模糊搜索
if not cls.CONDITION_PATTERN.search(query):
# 裸文本,默认作为 host 模糊搜索
return "host ILIKE %s", [f"%{query}%"]
# 裸文本,默认作为 host 模糊搜索v 是视图别名)
return "v.host ILIKE %s", [f"%{query}%"]
# 按 || 分割为 OR 组
or_groups = cls._split_by_or(query)
@@ -274,45 +282,45 @@ class SearchQueryParser:
def _build_like_condition(cls, field: str, value: str, is_array: bool) -> Tuple[str, List[Any]]:
"""构建模糊匹配条件"""
if is_array:
# 数组字段:检查数组中是否有元素包含该值
return f"EXISTS (SELECT 1 FROM unnest({field}) AS t WHERE t ILIKE %s)", [f"%{value}%"]
# 数组字段:检查数组中是否有元素包含该值(从原表 t 获取)
return f"EXISTS (SELECT 1 FROM unnest(t.{field}) AS elem WHERE elem ILIKE %s)", [f"%{value}%"]
elif field == 'status_code':
# 状态码是整数,模糊匹配转为精确匹配
try:
return f"{field} = %s", [int(value)]
return f"v.{field} = %s", [int(value)]
except ValueError:
return f"{field}::text ILIKE %s", [f"%{value}%"]
return f"v.{field}::text ILIKE %s", [f"%{value}%"]
else:
return f"{field} ILIKE %s", [f"%{value}%"]
return f"v.{field} ILIKE %s", [f"%{value}%"]
@classmethod
def _build_exact_condition(cls, field: str, value: str, is_array: bool) -> Tuple[str, List[Any]]:
"""构建精确匹配条件"""
if is_array:
# 数组字段:检查数组中是否包含该精确值
return f"%s = ANY({field})", [value]
# 数组字段:检查数组中是否包含该精确值(从原表 t 获取)
return f"%s = ANY(t.{field})", [value]
elif field == 'status_code':
# 状态码是整数
try:
return f"{field} = %s", [int(value)]
return f"v.{field} = %s", [int(value)]
except ValueError:
return f"{field}::text = %s", [value]
return f"v.{field}::text = %s", [value]
else:
return f"{field} = %s", [value]
return f"v.{field} = %s", [value]
@classmethod
def _build_not_equal_condition(cls, field: str, value: str, is_array: bool) -> Tuple[str, List[Any]]:
"""构建不等于条件"""
if is_array:
# 数组字段:检查数组中不包含该值
return f"NOT (%s = ANY({field}))", [value]
# 数组字段:检查数组中不包含该值(从原表 t 获取)
return f"NOT (%s = ANY(t.{field}))", [value]
elif field == 'status_code':
try:
return f"({field} IS NULL OR {field} != %s)", [int(value)]
return f"(v.{field} IS NULL OR v.{field} != %s)", [int(value)]
except ValueError:
return f"({field} IS NULL OR {field}::text != %s)", [value]
return f"(v.{field} IS NULL OR v.{field}::text != %s)", [value]
else:
return f"({field} IS NULL OR {field} != %s)", [value]
return f"(v.{field} IS NULL OR v.{field} != %s)", [value]
AssetType = Literal['website', 'endpoint']
@@ -340,15 +348,18 @@ class AssetSearchService:
"""
where_clause, params = SearchQueryParser.parse(query)
# 根据资产类型选择视图和字段
# 根据资产类型选择视图、原表和字段
view_name = VIEW_MAPPING.get(asset_type, 'asset_search_view')
table_name = TABLE_MAPPING.get(asset_type, 'website')
select_fields = ENDPOINT_SELECT_FIELDS if asset_type == 'endpoint' else WEBSITE_SELECT_FIELDS
# JOIN 原表获取数组字段tech, matched_gf_patterns
sql = f"""
SELECT {select_fields}
FROM {view_name}
FROM {view_name} v
JOIN {table_name} t ON v.id = t.id
WHERE {where_clause}
ORDER BY created_at DESC
ORDER BY v.created_at DESC
"""
# 添加 LIMIT
@@ -370,26 +381,31 @@ class AssetSearchService:
logger.error(f"搜索查询失败: {e}, SQL: {sql}, params: {params}")
raise
def count(self, query: str, asset_type: AssetType = 'website') -> int:
def count(self, query: str, asset_type: AssetType = 'website', statement_timeout_ms: int = 300000) -> int:
"""
统计搜索结果数量
Args:
query: 搜索查询字符串
asset_type: 资产类型 ('website''endpoint')
statement_timeout_ms: SQL 语句超时时间(毫秒),默认 5 分钟
Returns:
int: 结果总数
"""
where_clause, params = SearchQueryParser.parse(query)
# 根据资产类型选择视图
# 根据资产类型选择视图和原表
view_name = VIEW_MAPPING.get(asset_type, 'asset_search_view')
table_name = TABLE_MAPPING.get(asset_type, 'website')
sql = f"SELECT COUNT(*) FROM {view_name} WHERE {where_clause}"
# JOIN 原表以支持数组字段查询
sql = f"SELECT COUNT(*) FROM {view_name} v JOIN {table_name} t ON v.id = t.id WHERE {where_clause}"
try:
with connection.cursor() as cursor:
# 为导出设置更长的超时时间(仅影响当前会话)
cursor.execute(f"SET LOCAL statement_timeout = {statement_timeout_ms}")
cursor.execute(sql, params)
return cursor.fetchone()[0]
except Exception as e:
@@ -400,44 +416,62 @@ class AssetSearchService:
self,
query: str,
asset_type: AssetType = 'website',
batch_size: int = 1000
batch_size: int = 1000,
statement_timeout_ms: int = 300000
) -> Iterator[Dict[str, Any]]:
"""
流式搜索资产(使用服务端游标,内存友好)
流式搜索资产(使用分批查询,内存友好)
Args:
query: 搜索查询字符串
asset_type: 资产类型 ('website''endpoint')
batch_size: 每批获取的数量
statement_timeout_ms: SQL 语句超时时间(毫秒),默认 5 分钟
Yields:
Dict: 单条搜索结果
"""
where_clause, params = SearchQueryParser.parse(query)
# 根据资产类型选择视图和字段
# 根据资产类型选择视图、原表和字段
view_name = VIEW_MAPPING.get(asset_type, 'asset_search_view')
table_name = TABLE_MAPPING.get(asset_type, 'website')
select_fields = ENDPOINT_SELECT_FIELDS if asset_type == 'endpoint' else WEBSITE_SELECT_FIELDS
sql = f"""
SELECT {select_fields}
FROM {view_name}
WHERE {where_clause}
ORDER BY created_at DESC
"""
# 生成唯一的游标名称,避免并发请求冲突
cursor_name = f'export_cursor_{uuid.uuid4().hex[:8]}'
# 使用 OFFSET/LIMIT 分批查询Django 不支持命名游标)
offset = 0
try:
# 使用服务端游标,避免一次性加载所有数据到内存
with connection.cursor(name=cursor_name) as cursor:
cursor.itersize = batch_size
cursor.execute(sql, params)
columns = [col[0] for col in cursor.description]
while True:
# JOIN 原表获取数组字段
sql = f"""
SELECT {select_fields}
FROM {view_name} v
JOIN {table_name} t ON v.id = t.id
WHERE {where_clause}
ORDER BY v.created_at DESC
LIMIT {batch_size} OFFSET {offset}
"""
for row in cursor:
with connection.cursor() as cursor:
# 为导出设置更长的超时时间(仅影响当前会话)
cursor.execute(f"SET LOCAL statement_timeout = {statement_timeout_ms}")
cursor.execute(sql, params)
columns = [col[0] for col in cursor.description]
rows = cursor.fetchall()
if not rows:
break
for row in rows:
yield dict(zip(columns, row))
# 如果返回的行数少于 batch_size说明已经是最后一批
if len(rows) < batch_size:
break
offset += batch_size
except Exception as e:
logger.error(f"流式搜索查询失败: {e}, SQL: {sql}, params: {params}")
raise

View File

@@ -8,7 +8,6 @@ from rest_framework.request import Request
from rest_framework.exceptions import NotFound, ValidationError as DRFValidationError
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.db import DatabaseError, IntegrityError, OperationalError
from django.http import StreamingHttpResponse
from ..serializers import (
SubdomainListSerializer, WebSiteSerializer, DirectorySerializer,
@@ -243,7 +242,7 @@ class SubdomainViewSet(viewsets.ModelViewSet):
CSV 列name, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime
from apps.common.utils import create_csv_export_response, format_datetime
target_pk = self.kwargs.get('target_pk')
if not target_pk:
@@ -254,12 +253,12 @@ class SubdomainViewSet(viewsets.ModelViewSet):
headers = ['name', 'created_at']
formatters = {'created_at': format_datetime}
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"target-{target_pk}-subdomains.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-subdomains.csv"'
return response
class WebSiteViewSet(viewsets.ModelViewSet):
@@ -369,7 +368,7 @@ class WebSiteViewSet(viewsets.ModelViewSet):
CSV 列url, host, location, title, status_code, content_length, content_type, webserver, tech, response_body, response_headers, vhost, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime, format_list_field
from apps.common.utils import create_csv_export_response, format_datetime, format_list_field
target_pk = self.kwargs.get('target_pk')
if not target_pk:
@@ -387,12 +386,12 @@ class WebSiteViewSet(viewsets.ModelViewSet):
'tech': lambda x: format_list_field(x, separator=','),
}
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"target-{target_pk}-websites.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-websites.csv"'
return response
class DirectoryViewSet(viewsets.ModelViewSet):
@@ -499,7 +498,7 @@ class DirectoryViewSet(viewsets.ModelViewSet):
CSV 列url, status, content_length, words, lines, content_type, duration, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime
from apps.common.utils import create_csv_export_response, format_datetime
target_pk = self.kwargs.get('target_pk')
if not target_pk:
@@ -515,12 +514,12 @@ class DirectoryViewSet(viewsets.ModelViewSet):
'created_at': format_datetime,
}
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"target-{target_pk}-directories.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-directories.csv"'
return response
class EndpointViewSet(viewsets.ModelViewSet):
@@ -630,7 +629,7 @@ class EndpointViewSet(viewsets.ModelViewSet):
CSV 列url, host, location, title, status_code, content_length, content_type, webserver, tech, response_body, response_headers, vhost, matched_gf_patterns, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime, format_list_field
from apps.common.utils import create_csv_export_response, format_datetime, format_list_field
target_pk = self.kwargs.get('target_pk')
if not target_pk:
@@ -649,12 +648,12 @@ class EndpointViewSet(viewsets.ModelViewSet):
'matched_gf_patterns': lambda x: format_list_field(x, separator=','),
}
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"target-{target_pk}-endpoints.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-endpoints.csv"'
return response
class HostPortMappingViewSet(viewsets.ModelViewSet):
@@ -707,7 +706,7 @@ class HostPortMappingViewSet(viewsets.ModelViewSet):
CSV 列ip, host, port, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime
from apps.common.utils import create_csv_export_response, format_datetime
target_pk = self.kwargs.get('target_pk')
if not target_pk:
@@ -722,14 +721,12 @@ class HostPortMappingViewSet(viewsets.ModelViewSet):
'created_at': format_datetime
}
# 生成流式响应
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"target-{target_pk}-ip-addresses.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-ip-addresses.csv"'
return response
class VulnerabilityViewSet(viewsets.ModelViewSet):
@@ -801,7 +798,7 @@ class SubdomainSnapshotViewSet(viewsets.ModelViewSet):
CSV 列name, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime
from apps.common.utils import create_csv_export_response, format_datetime
scan_pk = self.kwargs.get('scan_pk')
if not scan_pk:
@@ -812,12 +809,12 @@ class SubdomainSnapshotViewSet(viewsets.ModelViewSet):
headers = ['name', 'created_at']
formatters = {'created_at': format_datetime}
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"scan-{scan_pk}-subdomains.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-subdomains.csv"'
return response
class WebsiteSnapshotViewSet(viewsets.ModelViewSet):
@@ -855,7 +852,7 @@ class WebsiteSnapshotViewSet(viewsets.ModelViewSet):
CSV 列url, host, location, title, status_code, content_length, content_type, webserver, tech, response_body, response_headers, vhost, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime, format_list_field
from apps.common.utils import create_csv_export_response, format_datetime, format_list_field
scan_pk = self.kwargs.get('scan_pk')
if not scan_pk:
@@ -873,12 +870,12 @@ class WebsiteSnapshotViewSet(viewsets.ModelViewSet):
'tech': lambda x: format_list_field(x, separator=','),
}
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"scan-{scan_pk}-websites.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-websites.csv"'
return response
class DirectorySnapshotViewSet(viewsets.ModelViewSet):
@@ -913,7 +910,7 @@ class DirectorySnapshotViewSet(viewsets.ModelViewSet):
CSV 列url, status, content_length, words, lines, content_type, duration, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime
from apps.common.utils import create_csv_export_response, format_datetime
scan_pk = self.kwargs.get('scan_pk')
if not scan_pk:
@@ -929,12 +926,12 @@ class DirectorySnapshotViewSet(viewsets.ModelViewSet):
'created_at': format_datetime,
}
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"scan-{scan_pk}-directories.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-directories.csv"'
return response
class EndpointSnapshotViewSet(viewsets.ModelViewSet):
@@ -972,7 +969,7 @@ class EndpointSnapshotViewSet(viewsets.ModelViewSet):
CSV 列url, host, location, title, status_code, content_length, content_type, webserver, tech, response_body, response_headers, vhost, matched_gf_patterns, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime, format_list_field
from apps.common.utils import create_csv_export_response, format_datetime, format_list_field
scan_pk = self.kwargs.get('scan_pk')
if not scan_pk:
@@ -991,12 +988,12 @@ class EndpointSnapshotViewSet(viewsets.ModelViewSet):
'matched_gf_patterns': lambda x: format_list_field(x, separator=','),
}
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"scan-{scan_pk}-endpoints.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-endpoints.csv"'
return response
class HostPortMappingSnapshotViewSet(viewsets.ModelViewSet):
@@ -1031,7 +1028,7 @@ class HostPortMappingSnapshotViewSet(viewsets.ModelViewSet):
CSV 列ip, host, port, created_at
"""
from apps.common.utils import generate_csv_rows, format_datetime
from apps.common.utils import create_csv_export_response, format_datetime
scan_pk = self.kwargs.get('scan_pk')
if not scan_pk:
@@ -1046,14 +1043,12 @@ class HostPortMappingSnapshotViewSet(viewsets.ModelViewSet):
'created_at': format_datetime
}
# 生成流式响应
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=f"scan-{scan_pk}-ip-addresses.csv",
field_formatters=formatters
)
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-ip-addresses.csv"'
return response
class VulnerabilitySnapshotViewSet(viewsets.ModelViewSet):

View File

@@ -33,9 +33,7 @@ from urllib.parse import urlparse, urlunparse
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.request import Request
from django.http import StreamingHttpResponse
from django.db import connection, transaction
from django.utils.decorators import method_decorator
from django.db import connection
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
@@ -286,10 +284,7 @@ class AssetSearchExportView(APIView):
asset_type: 资产类型 ('website''endpoint',默认 'website')
Response:
CSV 文件流(使用服务端游标,支持大数据量导出
注意:使用 @transaction.non_atomic_requests 装饰器,
因为服务端游标不能在事务块内使用。
CSV 文件(带 Content-Length支持浏览器显示下载进度
"""
def __init__(self, **kwargs):
@@ -316,10 +311,9 @@ class AssetSearchExportView(APIView):
return headers, formatters
@method_decorator(transaction.non_atomic_requests)
def get(self, request: Request):
"""导出搜索结果为 CSV流式导出,无数量限制"""
from apps.common.utils import generate_csv_rows
"""导出搜索结果为 CSV带 Content-Length支持下载进度显示"""
from apps.common.utils import create_csv_export_response
# 获取搜索查询
query = request.query_params.get('q', '').strip()
@@ -352,18 +346,16 @@ class AssetSearchExportView(APIView):
# 获取表头和格式化器
headers, formatters = self._get_headers_and_formatters(asset_type)
# 获取流式数据迭代器
data_iterator = self.service.search_iter(query, asset_type)
# 生成文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'search_{asset_type}_{timestamp}.csv'
# 返回流式响应
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
# 使用通用导出工具
data_iterator = self.service.search_iter(query, asset_type)
return create_csv_export_response(
data_iterator=data_iterator,
headers=headers,
filename=filename,
field_formatters=formatters,
show_progress=True # 显示下载进度
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response

View File

@@ -11,6 +11,7 @@ from .csv_utils import (
generate_csv_rows,
format_list_field,
format_datetime,
create_csv_export_response,
UTF8_BOM,
)
@@ -24,5 +25,6 @@ __all__ = [
'generate_csv_rows',
'format_list_field',
'format_datetime',
'create_csv_export_response',
'UTF8_BOM',
]

View File

@@ -4,13 +4,21 @@
- UTF-8 BOMExcel 兼容)
- RFC 4180 规范转义
- 流式生成(内存友好)
- 带 Content-Length 的文件响应(支持浏览器下载进度显示)
"""
import csv
import io
import os
import tempfile
import logging
from datetime import datetime
from typing import Iterator, Dict, Any, List, Callable, Optional
from django.http import FileResponse, StreamingHttpResponse
logger = logging.getLogger(__name__)
# UTF-8 BOM确保 Excel 正确识别编码
UTF8_BOM = '\ufeff'
@@ -114,3 +122,123 @@ def format_datetime(dt: Optional[datetime]) -> str:
dt = timezone.localtime(dt)
return dt.strftime('%Y-%m-%d %H:%M:%S')
def create_csv_export_response(
data_iterator: Iterator[Dict[str, Any]],
headers: List[str],
filename: str,
field_formatters: Optional[Dict[str, Callable]] = None,
show_progress: bool = True
) -> FileResponse | StreamingHttpResponse:
"""
创建 CSV 导出响应
根据 show_progress 参数选择响应类型:
- True: 使用临时文件 + FileResponse带 Content-Length浏览器显示下载进度
- False: 使用 StreamingHttpResponse内存更友好但无下载进度
Args:
data_iterator: 数据迭代器,每个元素是一个字典
headers: CSV 表头列表
filename: 下载文件名(如 "export_2024.csv"
field_formatters: 字段格式化函数字典
show_progress: 是否显示下载进度(默认 True
Returns:
FileResponse 或 StreamingHttpResponse
Example:
>>> data_iter = service.iter_data()
>>> headers = ['url', 'host', 'created_at']
>>> formatters = {'created_at': format_datetime}
>>> response = create_csv_export_response(
... data_iter, headers, 'websites.csv', formatters
... )
>>> return response
"""
if show_progress:
return _create_file_response(data_iterator, headers, filename, field_formatters)
else:
return _create_streaming_response(data_iterator, headers, filename, field_formatters)
def _create_file_response(
data_iterator: Iterator[Dict[str, Any]],
headers: List[str],
filename: str,
field_formatters: Optional[Dict[str, Callable]] = None
) -> FileResponse:
"""
创建带 Content-Length 的文件响应(支持浏览器下载进度)
实现方式:先写入临时文件,再返回 FileResponse
"""
# 创建临时文件
temp_file = tempfile.NamedTemporaryFile(
mode='w',
suffix='.csv',
delete=False,
encoding='utf-8'
)
temp_path = temp_file.name
try:
# 流式写入 CSV 数据到临时文件
for row in generate_csv_rows(data_iterator, headers, field_formatters):
temp_file.write(row)
temp_file.close()
# 获取文件大小
file_size = os.path.getsize(temp_path)
# 创建文件响应
response = FileResponse(
open(temp_path, 'rb'),
content_type='text/csv; charset=utf-8',
as_attachment=True,
filename=filename
)
response['Content-Length'] = file_size
# 设置清理回调:响应完成后删除临时文件
original_close = response.file_to_stream.close
def close_and_cleanup():
original_close()
try:
os.unlink(temp_path)
except OSError:
pass
response.file_to_stream.close = close_and_cleanup
return response
except Exception as e:
# 清理临时文件
try:
temp_file.close()
except:
pass
try:
os.unlink(temp_path)
except OSError:
pass
logger.error(f"创建 CSV 导出响应失败: {e}")
raise
def _create_streaming_response(
data_iterator: Iterator[Dict[str, Any]],
headers: List[str],
filename: str,
field_formatters: Optional[Dict[str, Callable]] = None
) -> StreamingHttpResponse:
"""
创建流式响应(无 Content-Length内存更友好
"""
response = StreamingHttpResponse(
generate_csv_rows(data_iterator, headers, field_formatters),
content_type='text/csv; charset=utf-8'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response

View File

@@ -33,7 +33,7 @@ from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.utils import config_parser, build_scan_command, ensure_wordlist_local
from apps.scan.utils import config_parser, build_scan_command, ensure_wordlist_local, user_log
logger = logging.getLogger(__name__)
@@ -413,6 +413,7 @@ def _run_scans_concurrently(
logger.info("="*60)
logger.info("使用工具: %s (并发模式, max_workers=%d)", tool_name, max_workers)
logger.info("="*60)
user_log(scan_id, "directory_scan", f"Running {tool_name}")
# 如果配置了 wordlist_name则先确保本地存在对应的字典文件含 hash 校验)
wordlist_name = tool_config.get('wordlist_name')
@@ -467,6 +468,11 @@ def _run_scans_concurrently(
total_tasks = len(scan_params_list)
logger.info("开始分批执行 %d 个扫描任务(每批 %d 个)...", total_tasks, max_workers)
# 进度里程碑跟踪
last_progress_percent = 0
tool_directories = 0
tool_processed = 0
batch_num = 0
for batch_start in range(0, total_tasks, max_workers):
batch_end = min(batch_start + max_workers, total_tasks)
@@ -498,7 +504,9 @@ def _run_scans_concurrently(
result = future.result() # 阻塞等待单个任务完成
directories_found = result.get('created_directories', 0)
total_directories += directories_found
tool_directories += directories_found
processed_sites_count += 1
tool_processed += 1
logger.info(
"✓ [%d/%d] 站点扫描完成: %s - 发现 %d 个目录",
@@ -517,6 +525,19 @@ def _run_scans_concurrently(
"✗ [%d/%d] 站点扫描失败: %s - 错误: %s",
idx, len(sites), site_url, exc
)
# 进度里程碑:每 20% 输出一次
current_progress = int((batch_end / total_tasks) * 100)
if current_progress >= last_progress_percent + 20:
user_log(scan_id, "directory_scan", f"Progress: {batch_end}/{total_tasks} sites scanned")
last_progress_percent = (current_progress // 20) * 20
# 工具完成日志(开发者日志 + 用户日志)
logger.info(
"✓ 工具 %s 执行完成 - 已处理站点: %d/%d, 发现目录: %d",
tool_name, tool_processed, total_tasks, tool_directories
)
user_log(scan_id, "directory_scan", f"{tool_name} completed: found {tool_directories} directories")
# 输出汇总信息
if failed_sites:
@@ -605,6 +626,8 @@ def directory_scan_flow(
"="*60
)
user_log(scan_id, "directory_scan", "Starting directory scan")
# 参数验证
if scan_id is None:
raise ValueError("scan_id 不能为空")
@@ -625,7 +648,8 @@ def directory_scan_flow(
sites_file, site_count = _export_site_urls(target_id, target_name, directory_scan_dir)
if site_count == 0:
logger.warning("目标下没有站点,跳过目录扫描")
logger.warning("跳过目录扫描:没有站点可扫描 - Scan ID: %s", scan_id)
user_log(scan_id, "directory_scan", "Skipped: no sites to scan", "warning")
return {
'success': True,
'scan_id': scan_id,
@@ -664,7 +688,9 @@ def directory_scan_flow(
logger.warning("所有站点扫描均失败 - 总站点数: %d, 失败数: %d", site_count, len(failed_sites))
# 不抛出异常,让扫描继续
logger.info("="*60 + "\n✓ 目录扫描完成\n" + "="*60)
# 记录 Flow 完成
logger.info("✓ 目录扫描完成 - 发现目录: %d", total_directories)
user_log(scan_id, "directory_scan", f"directory_scan completed: found {total_directories} directories")
return {
'success': True,

View File

@@ -29,7 +29,7 @@ from apps.scan.tasks.fingerprint_detect import (
export_urls_for_fingerprint_task,
run_xingfinger_and_stream_update_tech_task,
)
from apps.scan.utils import build_scan_command
from apps.scan.utils import build_scan_command, user_log
from apps.scan.utils.fingerprint_helpers import get_fingerprint_paths
logger = logging.getLogger(__name__)
@@ -168,6 +168,7 @@ def _run_fingerprint_detect(
"开始执行 %s 指纹识别 - URL数: %d, 超时: %ds, 指纹库: %s",
tool_name, url_count, timeout, list(fingerprint_paths.keys())
)
user_log(scan_id, "fingerprint_detect", f"Running {tool_name}: {command}")
# 6. 执行扫描任务
try:
@@ -190,17 +191,21 @@ def _run_fingerprint_detect(
'fingerprint_libs': list(fingerprint_paths.keys())
}
tool_updated = result.get('updated_count', 0)
logger.info(
"✓ 工具 %s 执行完成 - 处理记录: %d, 更新: %d, 未找到: %d",
tool_name,
result.get('processed_records', 0),
result.get('updated_count', 0),
tool_updated,
result.get('not_found_count', 0)
)
user_log(scan_id, "fingerprint_detect", f"{tool_name} completed: identified {tool_updated} fingerprints")
except Exception as exc:
failed_tools.append({'tool': tool_name, 'reason': str(exc)})
reason = str(exc)
failed_tools.append({'tool': tool_name, 'reason': reason})
logger.error("工具 %s 执行失败: %s", tool_name, exc, exc_info=True)
user_log(scan_id, "fingerprint_detect", f"{tool_name} failed: {reason}", "error")
if failed_tools:
logger.warning(
@@ -272,6 +277,8 @@ def fingerprint_detect_flow(
"="*60
)
user_log(scan_id, "fingerprint_detect", "Starting fingerprint detection")
# 参数验证
if scan_id is None:
raise ValueError("scan_id 不能为空")
@@ -293,7 +300,8 @@ def fingerprint_detect_flow(
urls_file, url_count = _export_urls(target_id, fingerprint_dir, source)
if url_count == 0:
logger.warning("目标下没有可用的 URL跳过指纹识别")
logger.warning("跳过指纹识别:没有 URL 可扫描 - Scan ID: %s", scan_id)
user_log(scan_id, "fingerprint_detect", "Skipped: no URLs to scan", "warning")
return {
'success': True,
'scan_id': scan_id,
@@ -332,8 +340,6 @@ def fingerprint_detect_flow(
source=source
)
logger.info("="*60 + "\n✓ 指纹识别完成\n" + "="*60)
# 动态生成已执行的任务列表
executed_tasks = ['export_urls_for_fingerprint']
executed_tasks.extend([f'run_xingfinger ({tool})' for tool in tool_stats.keys()])
@@ -344,6 +350,10 @@ def fingerprint_detect_flow(
total_created = sum(stats['result'].get('created_count', 0) for stats in tool_stats.values())
total_snapshots = sum(stats['result'].get('snapshot_count', 0) for stats in tool_stats.values())
# 记录 Flow 完成
logger.info("✓ 指纹识别完成 - 识别指纹: %d", total_updated)
user_log(scan_id, "fingerprint_detect", f"fingerprint_detect completed: identified {total_updated} fingerprints")
successful_tools = [name for name in enabled_tools.keys()
if name not in [f['tool'] for f in failed_tools]]

View File

@@ -28,7 +28,7 @@ from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.utils import config_parser, build_scan_command
from apps.scan.utils import config_parser, build_scan_command, user_log
logger = logging.getLogger(__name__)
@@ -265,6 +265,7 @@ def _run_scans_sequentially(
# 3. 执行扫描任务
logger.info("开始执行 %s 扫描(超时: %d秒)...", tool_name, config_timeout)
user_log(scan_id, "port_scan", f"Running {tool_name}: {command}")
try:
# 直接调用 task串行执行
@@ -286,26 +287,31 @@ def _run_scans_sequentially(
'result': result,
'timeout': config_timeout
}
processed_records += result.get('processed_records', 0)
tool_records = result.get('processed_records', 0)
processed_records += tool_records
logger.info(
"✓ 工具 %s 流式处理完成 - 记录数: %d",
tool_name, result.get('processed_records', 0)
tool_name, tool_records
)
user_log(scan_id, "port_scan", f"{tool_name} completed: found {tool_records} ports")
except subprocess.TimeoutExpired as exc:
# 超时异常单独处理
# 注意:流式处理任务超时时,已解析的数据已保存到数据库
reason = f"执行超时(配置: {config_timeout}秒)"
reason = f"timeout after {config_timeout}s"
failed_tools.append({'tool': tool_name, 'reason': reason})
logger.warning(
"⚠️ 工具 %s 执行超时 - 超时配置: %d\n"
"注意:超时前已解析的端口数据已保存到数据库,但扫描未完全完成。",
tool_name, config_timeout
)
user_log(scan_id, "port_scan", f"{tool_name} failed: {reason}", "error")
except Exception as exc:
# 其他异常
failed_tools.append({'tool': tool_name, 'reason': str(exc)})
reason = str(exc)
failed_tools.append({'tool': tool_name, 'reason': reason})
logger.error("工具 %s 执行失败: %s", tool_name, exc, exc_info=True)
user_log(scan_id, "port_scan", f"{tool_name} failed: {reason}", "error")
if failed_tools:
logger.warning(
@@ -420,6 +426,8 @@ def port_scan_flow(
"="*60
)
user_log(scan_id, "port_scan", "Starting port scan")
# Step 0: 创建工作目录
from apps.scan.utils import setup_scan_directory
port_scan_dir = setup_scan_directory(scan_workspace_dir, 'port_scan')
@@ -428,7 +436,8 @@ def port_scan_flow(
targets_file, target_count, target_type = _export_scan_targets(target_id, port_scan_dir)
if target_count == 0:
logger.warning("目标下没有可扫描的地址,跳过端口扫描")
logger.warning("跳过端口扫描:没有目标可扫描 - Scan ID: %s", scan_id)
user_log(scan_id, "port_scan", "Skipped: no targets to scan", "warning")
return {
'success': True,
'scan_id': scan_id,
@@ -467,7 +476,9 @@ def port_scan_flow(
target_name=target_name
)
logger.info("="*60 + "\n✓ 端口扫描完成\n" + "="*60)
# 记录 Flow 完成
logger.info("✓ 端口扫描完成 - 发现端口: %d", processed_records)
user_log(scan_id, "port_scan", f"port_scan completed: found {processed_records} ports")
# 动态生成已执行的任务列表
executed_tasks = ['export_scan_targets', 'parse_config']

View File

@@ -17,6 +17,7 @@ from apps.common.prefect_django_setup import setup_django_for_prefect
import logging
import os
import subprocess
import time
from pathlib import Path
from typing import Callable
from prefect import flow
@@ -26,7 +27,7 @@ from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.utils import config_parser, build_scan_command
from apps.scan.utils import config_parser, build_scan_command, user_log
logger = logging.getLogger(__name__)
@@ -198,6 +199,7 @@ def _run_scans_sequentially(
"开始执行 %s 站点扫描 - URL数: %d, 最终超时: %ds",
tool_name, total_urls, timeout
)
user_log(scan_id, "site_scan", f"Running {tool_name}: {command}")
# 3. 执行扫描任务
try:
@@ -218,29 +220,35 @@ def _run_scans_sequentially(
'result': result,
'timeout': timeout
}
processed_records += result.get('processed_records', 0)
tool_records = result.get('processed_records', 0)
tool_created = result.get('created_websites', 0)
processed_records += tool_records
logger.info(
"✓ 工具 %s 流式处理完成 - 处理记录: %d, 创建站点: %d, 跳过: %d",
tool_name,
result.get('processed_records', 0),
result.get('created_websites', 0),
tool_records,
tool_created,
result.get('skipped_no_subdomain', 0) + result.get('skipped_failed', 0)
)
user_log(scan_id, "site_scan", f"{tool_name} completed: found {tool_created} websites")
except subprocess.TimeoutExpired as exc:
# 超时异常单独处理
reason = f"执行超时(配置: {timeout}秒)"
reason = f"timeout after {timeout}s"
failed_tools.append({'tool': tool_name, 'reason': reason})
logger.warning(
"⚠️ 工具 %s 执行超时 - 超时配置: %d\n"
"注意:超时前已解析的站点数据已保存到数据库,但扫描未完全完成。",
tool_name, timeout
)
user_log(scan_id, "site_scan", f"{tool_name} failed: {reason}", "error")
except Exception as exc:
# 其他异常
failed_tools.append({'tool': tool_name, 'reason': str(exc)})
reason = str(exc)
failed_tools.append({'tool': tool_name, 'reason': reason})
logger.error("工具 %s 执行失败: %s", tool_name, exc, exc_info=True)
user_log(scan_id, "site_scan", f"{tool_name} failed: {reason}", "error")
if failed_tools:
logger.warning(
@@ -379,6 +387,8 @@ def site_scan_flow(
if not scan_workspace_dir:
raise ValueError("scan_workspace_dir 不能为空")
user_log(scan_id, "site_scan", "Starting site scan")
# Step 0: 创建工作目录
from apps.scan.utils import setup_scan_directory
site_scan_dir = setup_scan_directory(scan_workspace_dir, 'site_scan')
@@ -389,7 +399,8 @@ def site_scan_flow(
)
if total_urls == 0:
logger.warning("目标下没有可用的站点URL,跳过站点扫描")
logger.warning("跳过站点扫描:没有站点 URL 可扫描 - Scan ID: %s", scan_id)
user_log(scan_id, "site_scan", "Skipped: no site URLs to scan", "warning")
return {
'success': True,
'scan_id': scan_id,
@@ -432,8 +443,6 @@ def site_scan_flow(
target_name=target_name
)
logger.info("="*60 + "\n✓ 站点扫描完成\n" + "="*60)
# 动态生成已执行的任务列表
executed_tasks = ['export_site_urls', 'parse_config']
executed_tasks.extend([f'run_and_stream_save_websites ({tool})' for tool in tool_stats.keys()])
@@ -443,6 +452,10 @@ def site_scan_flow(
total_skipped_no_subdomain = sum(stats['result'].get('skipped_no_subdomain', 0) for stats in tool_stats.values())
total_skipped_failed = sum(stats['result'].get('skipped_failed', 0) for stats in tool_stats.values())
# 记录 Flow 完成
logger.info("✓ 站点扫描完成 - 创建站点: %d", total_created)
user_log(scan_id, "site_scan", f"site_scan completed: found {total_created} websites")
return {
'success': True,
'scan_id': scan_id,

View File

@@ -30,7 +30,7 @@ from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.utils import build_scan_command, ensure_wordlist_local
from apps.scan.utils import build_scan_command, ensure_wordlist_local, user_log
from apps.engine.services.wordlist_service import WordlistService
from apps.common.normalizer import normalize_domain
from apps.common.validators import validate_domain
@@ -77,7 +77,8 @@ def _validate_and_normalize_target(target_name: str) -> str:
def _run_scans_parallel(
enabled_tools: dict,
domain_name: str,
result_dir: Path
result_dir: Path,
scan_id: int
) -> tuple[list, list, list]:
"""
并行运行所有启用的子域名扫描工具
@@ -86,6 +87,7 @@ def _run_scans_parallel(
enabled_tools: 启用的工具配置字典 {'tool_name': {'timeout': 600, ...}}
domain_name: 目标域名
result_dir: 结果输出目录
scan_id: 扫描任务 ID用于记录日志
Returns:
tuple: (result_files, failed_tools, successful_tool_names)
@@ -137,6 +139,9 @@ def _run_scans_parallel(
f"提交任务 - 工具: {tool_name}, 超时: {timeout}s, 输出: {output_file}"
)
# 记录工具开始执行日志
user_log(scan_id, "subdomain_discovery", f"Running {tool_name}: {command}")
future = run_subdomain_discovery_task.submit(
tool=tool_name,
command=command,
@@ -164,16 +169,19 @@ def _run_scans_parallel(
if result:
result_files.append(result)
logger.info("✓ 扫描工具 %s 执行成功: %s", tool_name, result)
user_log(scan_id, "subdomain_discovery", f"{tool_name} completed")
else:
failure_msg = f"{tool_name}: 未生成结果文件"
failures.append(failure_msg)
failed_tools.append({'tool': tool_name, 'reason': '未生成结果文件'})
logger.warning("⚠️ 扫描工具 %s 未生成结果文件", tool_name)
user_log(scan_id, "subdomain_discovery", f"{tool_name} failed: no output file", "error")
except Exception as e:
failure_msg = f"{tool_name}: {str(e)}"
failures.append(failure_msg)
failed_tools.append({'tool': tool_name, 'reason': str(e)})
logger.warning("⚠️ 扫描工具 %s 执行失败: %s", tool_name, str(e))
user_log(scan_id, "subdomain_discovery", f"{tool_name} failed: {str(e)}", "error")
# 4. 检查是否有成功的工具
if not result_files:
@@ -203,7 +211,8 @@ def _run_single_tool(
tool_config: dict,
command_params: dict,
result_dir: Path,
scan_type: str = 'subdomain_discovery'
scan_type: str = 'subdomain_discovery',
scan_id: int = None
) -> str:
"""
运行单个扫描工具
@@ -214,6 +223,7 @@ def _run_single_tool(
command_params: 命令参数
result_dir: 结果目录
scan_type: 扫描类型
scan_id: 扫描 ID用于记录用户日志
Returns:
str: 输出文件路径,失败返回空字符串
@@ -242,7 +252,9 @@ def _run_single_tool(
if timeout == 'auto':
timeout = 3600
logger.info(f"执行 {tool_name}: timeout={timeout}s")
logger.info(f"执行 {tool_name}: {command}")
if scan_id:
user_log(scan_id, scan_type, f"Running {tool_name}: {command}")
try:
result = run_subdomain_discovery_task(
@@ -401,7 +413,6 @@ def subdomain_discovery_flow(
logger.warning("目标域名无效,跳过子域名发现扫描: %s", e)
return _empty_result(scan_id, target_name, scan_workspace_dir)
# 验证成功后打印日志
logger.info(
"="*60 + "\n" +
"开始子域名发现扫描\n" +
@@ -410,6 +421,7 @@ def subdomain_discovery_flow(
f" Workspace: {scan_workspace_dir}\n" +
"="*60
)
user_log(scan_id, "subdomain_discovery", f"Starting subdomain discovery for {domain_name}")
# 解析配置
passive_tools = scan_config.get('passive_tools', {})
@@ -429,23 +441,22 @@ def subdomain_discovery_flow(
successful_tool_names = []
# ==================== Stage 1: 被动收集(并行)====================
logger.info("=" * 40)
logger.info("Stage 1: 被动收集(并行)")
logger.info("=" * 40)
if enabled_passive_tools:
logger.info("=" * 40)
logger.info("Stage 1: 被动收集(并行)")
logger.info("=" * 40)
logger.info("启用工具: %s", ', '.join(enabled_passive_tools.keys()))
user_log(scan_id, "subdomain_discovery", f"Stage 1: passive collection ({', '.join(enabled_passive_tools.keys())})")
result_files, stage1_failed, stage1_success = _run_scans_parallel(
enabled_tools=enabled_passive_tools,
domain_name=domain_name,
result_dir=result_dir
result_dir=result_dir,
scan_id=scan_id
)
all_result_files.extend(result_files)
failed_tools.extend(stage1_failed)
successful_tool_names.extend(stage1_success)
executed_tasks.extend([f'passive ({tool})' for tool in stage1_success])
else:
logger.warning("未启用任何被动收集工具")
# 合并 Stage 1 结果
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
@@ -456,7 +467,6 @@ def subdomain_discovery_flow(
else:
# 创建空文件
Path(current_result).touch()
logger.warning("Stage 1 无结果,创建空文件")
# ==================== Stage 2: 字典爆破(可选)====================
bruteforce_enabled = bruteforce_config.get('enabled', False)
@@ -464,6 +474,7 @@ def subdomain_discovery_flow(
logger.info("=" * 40)
logger.info("Stage 2: 字典爆破")
logger.info("=" * 40)
user_log(scan_id, "subdomain_discovery", "Stage 2: bruteforce")
bruteforce_tool_config = bruteforce_config.get('subdomain_bruteforce', {})
wordlist_name = bruteforce_tool_config.get('wordlist_name', 'dns_wordlist.txt')
@@ -496,22 +507,16 @@ def subdomain_discovery_flow(
**bruteforce_tool_config,
'timeout': timeout_value,
}
logger.info(
"subdomain_bruteforce 使用自动 timeout: %s 秒 (字典行数=%s, 3秒/行)",
timeout_value,
line_count_int,
)
brute_output = str(result_dir / f"subs_brute_{timestamp}.txt")
brute_result = _run_single_tool(
tool_name='subdomain_bruteforce',
tool_config=bruteforce_tool_config,
command_params={
'domain': domain_name,
'wordlist': local_wordlist_path,
'output_file': brute_output
},
result_dir=result_dir
result_dir=result_dir,
scan_id=scan_id
)
if brute_result:
@@ -522,11 +527,16 @@ def subdomain_discovery_flow(
)
successful_tool_names.append('subdomain_bruteforce')
executed_tasks.append('bruteforce')
logger.info("✓ subdomain_bruteforce 执行完成")
user_log(scan_id, "subdomain_discovery", "subdomain_bruteforce completed")
else:
failed_tools.append({'tool': 'subdomain_bruteforce', 'reason': '执行失败'})
logger.warning("⚠️ subdomain_bruteforce 执行失败")
user_log(scan_id, "subdomain_discovery", "subdomain_bruteforce failed: execution failed", "error")
except Exception as exc:
logger.warning("字典准备失败,跳过字典爆破: %s", exc)
failed_tools.append({'tool': 'subdomain_bruteforce', 'reason': str(exc)})
logger.warning("字典准备失败,跳过字典爆破: %s", exc)
user_log(scan_id, "subdomain_discovery", f"subdomain_bruteforce failed: {str(exc)}", "error")
# ==================== Stage 3: 变异生成 + 验证(可选)====================
permutation_enabled = permutation_config.get('enabled', False)
@@ -534,6 +544,7 @@ def subdomain_discovery_flow(
logger.info("=" * 40)
logger.info("Stage 3: 变异生成 + 存活验证(流式管道)")
logger.info("=" * 40)
user_log(scan_id, "subdomain_discovery", "Stage 3: permutation + resolve")
permutation_tool_config = permutation_config.get('subdomain_permutation_resolve', {})
@@ -587,20 +598,19 @@ def subdomain_discovery_flow(
'tool': 'subdomain_permutation_resolve',
'reason': f"采样检测到泛解析 (膨胀率 {ratio:.1f}x)"
})
user_log(scan_id, "subdomain_discovery", f"subdomain_permutation_resolve skipped: wildcard detected (ratio {ratio:.1f}x)", "warning")
else:
# === Step 3.2: 采样通过,执行完整变异 ===
logger.info("采样检测通过,执行完整变异...")
permuted_output = str(result_dir / f"subs_permuted_{timestamp}.txt")
permuted_result = _run_single_tool(
tool_name='subdomain_permutation_resolve',
tool_config=permutation_tool_config,
command_params={
'input_file': current_result,
'output_file': permuted_output,
},
result_dir=result_dir
result_dir=result_dir,
scan_id=scan_id
)
if permuted_result:
@@ -611,15 +621,21 @@ def subdomain_discovery_flow(
)
successful_tool_names.append('subdomain_permutation_resolve')
executed_tasks.append('permutation')
logger.info("✓ subdomain_permutation_resolve 执行完成")
user_log(scan_id, "subdomain_discovery", "subdomain_permutation_resolve completed")
else:
failed_tools.append({'tool': 'subdomain_permutation_resolve', 'reason': '执行失败'})
logger.warning("⚠️ subdomain_permutation_resolve 执行失败")
user_log(scan_id, "subdomain_discovery", "subdomain_permutation_resolve failed: execution failed", "error")
except subprocess.TimeoutExpired:
logger.warning(f"采样检测超时 ({SAMPLE_TIMEOUT}秒),跳过变异")
failed_tools.append({'tool': 'subdomain_permutation_resolve', 'reason': '采样检测超时'})
logger.warning(f"采样检测超时 ({SAMPLE_TIMEOUT}秒),跳过变异")
user_log(scan_id, "subdomain_discovery", "subdomain_permutation_resolve failed: sample detection timeout", "error")
except Exception as e:
logger.warning(f"采样检测失败: {e},跳过变异")
failed_tools.append({'tool': 'subdomain_permutation_resolve', 'reason': f'采样检测失败: {e}'})
logger.warning(f"采样检测失败: {e},跳过变异")
user_log(scan_id, "subdomain_discovery", f"subdomain_permutation_resolve failed: {str(e)}", "error")
# ==================== Stage 4: DNS 存活验证(可选)====================
# 无论是否启用 Stage 3只要 resolve.enabled 为 true 就会执行,对当前所有候选子域做统一 DNS 验证
@@ -628,6 +644,7 @@ def subdomain_discovery_flow(
logger.info("=" * 40)
logger.info("Stage 4: DNS 存活验证")
logger.info("=" * 40)
user_log(scan_id, "subdomain_discovery", "Stage 4: DNS resolve")
resolve_tool_config = resolve_config.get('subdomain_resolve', {})
@@ -651,30 +668,27 @@ def subdomain_discovery_flow(
**resolve_tool_config,
'timeout': timeout_value,
}
logger.info(
"subdomain_resolve 使用自动 timeout: %s 秒 (候选子域数=%s, 3秒/域名)",
timeout_value,
line_count_int,
)
alive_output = str(result_dir / f"subs_alive_{timestamp}.txt")
alive_result = _run_single_tool(
tool_name='subdomain_resolve',
tool_config=resolve_tool_config,
command_params={
'input_file': current_result,
'output_file': alive_output,
},
result_dir=result_dir
result_dir=result_dir,
scan_id=scan_id
)
if alive_result:
current_result = alive_result
successful_tool_names.append('subdomain_resolve')
executed_tasks.append('resolve')
logger.info("✓ subdomain_resolve 执行完成")
user_log(scan_id, "subdomain_discovery", "subdomain_resolve completed")
else:
failed_tools.append({'tool': 'subdomain_resolve', 'reason': '执行失败'})
logger.warning("⚠️ subdomain_resolve 执行失败")
user_log(scan_id, "subdomain_discovery", "subdomain_resolve failed: execution failed", "error")
# ==================== Final: 保存到数据库 ====================
logger.info("=" * 40)
@@ -695,7 +709,9 @@ def subdomain_discovery_flow(
processed_domains = save_result.get('processed_records', 0)
executed_tasks.append('save_domains')
# 记录 Flow 完成
logger.info("="*60 + "\n✓ 子域名发现扫描完成\n" + "="*60)
user_log(scan_id, "subdomain_discovery", f"subdomain_discovery completed: found {processed_domains} subdomains")
return {
'success': True,

View File

@@ -59,6 +59,8 @@ def domain_name_url_fetch_flow(
- IP 和 CIDR 类型会自动跳过waymore 等工具不支持)
- 工具会自动收集 *.target_name 的所有历史 URL无需遍历子域名
"""
from apps.scan.utils import user_log
try:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
@@ -145,6 +147,9 @@ def domain_name_url_fetch_flow(
timeout,
)
# 记录工具开始执行日志
user_log(scan_id, "url_fetch", f"Running {tool_name}: {command}")
future = run_url_fetcher_task.submit(
tool_name=tool_name,
command=command,
@@ -163,22 +168,28 @@ def domain_name_url_fetch_flow(
if result and result.get("success"):
result_files.append(result["output_file"])
successful_tools.append(tool_name)
url_count = result.get("url_count", 0)
logger.info(
"✓ 工具 %s 执行成功 - 发现 URL: %d",
tool_name,
result.get("url_count", 0),
url_count,
)
user_log(scan_id, "url_fetch", f"{tool_name} completed: found {url_count} urls")
else:
reason = "未生成结果或无有效 URL"
failed_tools.append(
{
"tool": tool_name,
"reason": "未生成结果或无有效 URL",
"reason": reason,
}
)
logger.warning("⚠️ 工具 %s 未生成有效结果", tool_name)
user_log(scan_id, "url_fetch", f"{tool_name} failed: {reason}", "error")
except Exception as e:
failed_tools.append({"tool": tool_name, "reason": str(e)})
reason = str(e)
failed_tools.append({"tool": tool_name, "reason": reason})
logger.warning("⚠️ 工具 %s 执行失败: %s", tool_name, e)
user_log(scan_id, "url_fetch", f"{tool_name} failed: {reason}", "error")
logger.info(
"基于 domain_name 的 URL 获取完成 - 成功工具: %s, 失败工具: %s",

View File

@@ -25,6 +25,7 @@ from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.utils import user_log
from .domain_name_url_fetch_flow import domain_name_url_fetch_flow
from .sites_url_fetch_flow import sites_url_fetch_flow
@@ -291,6 +292,8 @@ def url_fetch_flow(
"="*60
)
user_log(scan_id, "url_fetch", "Starting URL fetch")
# Step 1: 准备工作目录
logger.info("Step 1: 准备工作目录")
from apps.scan.utils import setup_scan_directory
@@ -403,7 +406,9 @@ def url_fetch_flow(
target_id=target_id
)
logger.info("="*60 + "\n✓ URL 获取扫描完成\n" + "="*60)
# 记录 Flow 完成
logger.info("✓ URL 获取完成 - 保存 endpoints: %d", saved_count)
user_log(scan_id, "url_fetch", f"url_fetch completed: found {saved_count} endpoints")
# 构建已执行的任务列表
executed_tasks = ['setup_directory', 'classify_tools']

View File

@@ -116,7 +116,8 @@ def sites_url_fetch_flow(
tools=enabled_tools,
input_file=sites_file,
input_type="sites_file",
output_dir=output_path
output_dir=output_path,
scan_id=scan_id
)
logger.info(

View File

@@ -152,7 +152,8 @@ def run_tools_parallel(
tools: dict,
input_file: str,
input_type: str,
output_dir: Path
output_dir: Path,
scan_id: int
) -> tuple[list, list, list]:
"""
并行执行工具列表
@@ -162,11 +163,13 @@ def run_tools_parallel(
input_file: 输入文件路径
input_type: 输入类型
output_dir: 输出目录
scan_id: 扫描任务 ID用于记录日志
Returns:
tuple: (result_files, failed_tools, successful_tool_names)
"""
from apps.scan.tasks.url_fetch import run_url_fetcher_task
from apps.scan.utils import user_log
futures: dict[str, object] = {}
failed_tools: list[dict] = []
@@ -192,6 +195,9 @@ def run_tools_parallel(
exec_params["timeout"],
)
# 记录工具开始执行日志
user_log(scan_id, "url_fetch", f"Running {tool_name}: {exec_params['command']}")
# 提交并行任务
future = run_url_fetcher_task.submit(
tool_name=tool_name,
@@ -208,22 +214,28 @@ def run_tools_parallel(
result = future.result()
if result and result['success']:
result_files.append(result['output_file'])
url_count = result['url_count']
logger.info(
"✓ 工具 %s 执行成功 - 发现 URL: %d",
tool_name, result['url_count']
tool_name, url_count
)
user_log(scan_id, "url_fetch", f"{tool_name} completed: found {url_count} urls")
else:
reason = '未生成结果或无有效URL'
failed_tools.append({
'tool': tool_name,
'reason': '未生成结果或无有效URL'
'reason': reason
})
logger.warning("⚠️ 工具 %s 未生成有效结果", tool_name)
user_log(scan_id, "url_fetch", f"{tool_name} failed: {reason}", "error")
except Exception as e:
reason = str(e)
failed_tools.append({
'tool': tool_name,
'reason': str(e)
'reason': reason
})
logger.warning("⚠️ 工具 %s 执行失败: %s", tool_name, e)
user_log(scan_id, "url_fetch", f"{tool_name} failed: {reason}", "error")
# 计算成功的工具列表
failed_tool_names = [f['tool'] for f in failed_tools]

View File

@@ -12,7 +12,7 @@ from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.utils import build_scan_command, ensure_nuclei_templates_local
from apps.scan.utils import build_scan_command, ensure_nuclei_templates_local, user_log
from apps.scan.tasks.vuln_scan import (
export_endpoints_task,
run_vuln_tool_task,
@@ -141,6 +141,7 @@ def endpoints_vuln_scan_flow(
# Dalfox XSS 使用流式任务,一边解析一边保存漏洞结果
if tool_name == "dalfox_xss":
logger.info("开始执行漏洞扫描工具 %s(流式保存漏洞结果,已提交任务)", tool_name)
user_log(scan_id, "vuln_scan", f"Running {tool_name}: {command}")
future = run_and_stream_save_dalfox_vulns_task.submit(
cmd=command,
tool_name=tool_name,
@@ -163,6 +164,7 @@ def endpoints_vuln_scan_flow(
elif tool_name == "nuclei":
# Nuclei 使用流式任务
logger.info("开始执行漏洞扫描工具 %s(流式保存漏洞结果,已提交任务)", tool_name)
user_log(scan_id, "vuln_scan", f"Running {tool_name}: {command}")
future = run_and_stream_save_nuclei_vulns_task.submit(
cmd=command,
tool_name=tool_name,
@@ -185,6 +187,7 @@ def endpoints_vuln_scan_flow(
else:
# 其他工具仍使用非流式执行逻辑
logger.info("开始执行漏洞扫描工具 %s(已提交任务)", tool_name)
user_log(scan_id, "vuln_scan", f"Running {tool_name}: {command}")
future = run_vuln_tool_task.submit(
tool_name=tool_name,
command=command,
@@ -203,24 +206,34 @@ def endpoints_vuln_scan_flow(
# 统一收集所有工具的执行结果
for tool_name, meta in tool_futures.items():
future = meta["future"]
result = future.result()
try:
result = future.result()
if meta["mode"] == "streaming":
tool_results[tool_name] = {
"command": meta["command"],
"timeout": meta["timeout"],
"processed_records": result.get("processed_records"),
"created_vulns": result.get("created_vulns"),
"command_log_file": meta["log_file"],
}
else:
tool_results[tool_name] = {
"command": meta["command"],
"timeout": meta["timeout"],
"duration": result.get("duration"),
"returncode": result.get("returncode"),
"command_log_file": result.get("command_log_file"),
}
if meta["mode"] == "streaming":
created_vulns = result.get("created_vulns", 0)
tool_results[tool_name] = {
"command": meta["command"],
"timeout": meta["timeout"],
"processed_records": result.get("processed_records"),
"created_vulns": created_vulns,
"command_log_file": meta["log_file"],
}
logger.info("✓ 工具 %s 执行完成 - 漏洞: %d", tool_name, created_vulns)
user_log(scan_id, "vuln_scan", f"{tool_name} completed: found {created_vulns} vulnerabilities")
else:
tool_results[tool_name] = {
"command": meta["command"],
"timeout": meta["timeout"],
"duration": result.get("duration"),
"returncode": result.get("returncode"),
"command_log_file": result.get("command_log_file"),
}
logger.info("✓ 工具 %s 执行完成 - returncode=%s", tool_name, result.get("returncode"))
user_log(scan_id, "vuln_scan", f"{tool_name} completed")
except Exception as e:
reason = str(e)
logger.error("工具 %s 执行失败: %s", tool_name, e, exc_info=True)
user_log(scan_id, "vuln_scan", f"{tool_name} failed: {reason}", "error")
return {
"success": True,

View File

@@ -11,6 +11,7 @@ from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_failed,
)
from apps.scan.configs.command_templates import get_command_template
from apps.scan.utils import user_log
from .endpoints_vuln_scan_flow import endpoints_vuln_scan_flow
@@ -72,6 +73,9 @@ def vuln_scan_flow(
if not enabled_tools:
raise ValueError("enabled_tools 不能为空")
logger.info("开始漏洞扫描 - Scan ID: %s, Target: %s", scan_id, target_name)
user_log(scan_id, "vuln_scan", "Starting vulnerability scan")
# Step 1: 分类工具
endpoints_tools, other_tools = _classify_vuln_tools(enabled_tools)
@@ -99,6 +103,14 @@ def vuln_scan_flow(
enabled_tools=endpoints_tools,
)
# 记录 Flow 完成
total_vulns = sum(
r.get("created_vulns", 0)
for r in endpoint_result.get("tool_results", {}).values()
)
logger.info("✓ 漏洞扫描完成 - 新增漏洞: %d", total_vulns)
user_log(scan_id, "vuln_scan", f"vuln_scan completed: found {total_vulns} vulnerabilities")
# 目前只有一个子 Flow直接返回其结果
return endpoint_result

View File

@@ -14,6 +14,7 @@ from prefect import Flow
from prefect.client.schemas import FlowRun, State
from apps.scan.utils.performance import FlowPerformanceTracker
from apps.scan.utils import user_log
logger = logging.getLogger(__name__)
@@ -136,6 +137,7 @@ def on_scan_flow_failed(flow: Flow, flow_run: FlowRun, state: State) -> None:
- 更新阶段进度为 failed
- 发送扫描失败通知
- 记录性能指标(含错误信息)
- 写入 ScanLog 供前端显示
Args:
flow: Prefect Flow 对象
@@ -152,6 +154,11 @@ def on_scan_flow_failed(flow: Flow, flow_run: FlowRun, state: State) -> None:
# 提取错误信息
error_message = str(state.message) if state.message else "未知错误"
# 写入 ScanLog 供前端显示
stage = _get_stage_from_flow_name(flow.name)
if scan_id and stage:
user_log(scan_id, stage, f"Failed: {error_message}", "error")
# 记录性能指标(失败情况)
tracker = _flow_trackers.pop(str(flow_run.id), None)
if tracker:

View File

@@ -116,4 +116,21 @@ class Migration(migrations.Migration):
'indexes': [models.Index(fields=['-created_at'], name='scheduled_s_created_9b9c2e_idx'), models.Index(fields=['is_enabled', '-created_at'], name='scheduled_s_is_enab_23d660_idx'), models.Index(fields=['name'], name='scheduled_s_name_bf332d_idx')],
},
),
migrations.CreateModel(
name='ScanLog',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')], default='info', help_text='日志级别', max_length=10)),
('content', models.TextField(help_text='日志内容')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')),
('scan', models.ForeignKey(db_index=True, help_text='关联的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='scan.scan')),
],
options={
'verbose_name': '扫描日志',
'verbose_name_plural': '扫描日志',
'db_table': 'scan_log',
'ordering': ['created_at'],
'indexes': [models.Index(fields=['scan', 'created_at'], name='scan_log_scan_id_e8c8f5_idx')],
},
),
]

View File

@@ -106,6 +106,55 @@ class Scan(models.Model):
return f"Scan #{self.id} - {self.target.name}"
class ScanLog(models.Model):
"""扫描日志模型
存储扫描过程中的关键处理日志,用于前端实时查看扫描进度。
日志类型:
- 阶段开始/完成/失败
- 处理进度(如 "Progress: 50/120"
- 发现结果统计(如 "Found 120 subdomains"
- 错误信息
日志格式:[stage_name] message
"""
class Level(models.TextChoices):
INFO = 'info', 'Info'
WARNING = 'warning', 'Warning'
ERROR = 'error', 'Error'
id = models.BigAutoField(primary_key=True)
scan = models.ForeignKey(
'Scan',
on_delete=models.CASCADE,
related_name='logs',
db_index=True,
help_text='关联的扫描任务'
)
level = models.CharField(
max_length=10,
choices=Level.choices,
default=Level.INFO,
help_text='日志级别'
)
content = models.TextField(help_text='日志内容')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')
class Meta:
db_table = 'scan_log'
verbose_name = '扫描日志'
verbose_name_plural = '扫描日志'
ordering = ['created_at']
indexes = [
models.Index(fields=['scan', 'created_at']),
]
def __str__(self):
return f"[{self.level}] {self.content[:50]}"
class ScheduledScan(models.Model):
"""
定时扫描任务模型

View File

@@ -1,23 +1,60 @@
from rest_framework import serializers
from django.db.models import Count
import yaml
from .models import Scan, ScheduledScan
from .models import Scan, ScheduledScan, ScanLog
# ==================== 扫描日志序列化器 ====================
class ScanLogSerializer(serializers.ModelSerializer):
"""扫描日志序列化器"""
class Meta:
model = ScanLog
fields = ['id', 'level', 'content', 'created_at']
# ==================== 通用验证 Mixin ====================
class DuplicateKeyLoader(yaml.SafeLoader):
"""自定义 YAML Loader检测重复 key"""
pass
def _check_duplicate_keys(loader, node, deep=False):
"""检测 YAML mapping 中的重复 key"""
mapping = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in mapping:
raise yaml.constructor.ConstructorError(
"while constructing a mapping", node.start_mark,
f"发现重复的配置项 '{key}',后面的配置会覆盖前面的配置,请删除重复项", key_node.start_mark
)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
DuplicateKeyLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
_check_duplicate_keys
)
class ScanConfigValidationMixin:
"""扫描配置验证 Mixin提供通用的验证方法"""
def validate_configuration(self, value):
"""验证 YAML 配置格式"""
"""验证 YAML 配置格式,包括检测重复 key"""
import yaml
if not value or not value.strip():
raise serializers.ValidationError("configuration 不能为空")
try:
yaml.safe_load(value)
# 使用自定义 Loader 检测重复 key
yaml.load(value, Loader=DuplicateKeyLoader)
except yaml.YAMLError as e:
raise serializers.ValidationError(f"无效的 YAML 格式: {str(e)}")

View File

@@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ScanViewSet, ScheduledScanViewSet
from .views import ScanViewSet, ScheduledScanViewSet, ScanLogListView
from .notifications.views import notification_callback
from apps.asset.views import (
SubdomainSnapshotViewSet, WebsiteSnapshotViewSet, DirectorySnapshotViewSet,
@@ -31,6 +31,8 @@ urlpatterns = [
path('', include(router.urls)),
# Worker 回调 API
path('callbacks/notification/', notification_callback, name='notification-callback'),
# 扫描日志 API
path('scans/<int:scan_id>/logs/', ScanLogListView.as_view(), name='scan-logs-list'),
# 嵌套路由:/api/scans/{scan_pk}/xxx/
path('scans/<int:scan_pk>/subdomains/', scan_subdomains_list, name='scan-subdomains-list'),
path('scans/<int:scan_pk>/subdomains/export/', scan_subdomains_export, name='scan-subdomains-export'),

View File

@@ -11,6 +11,7 @@ from .wordlist_helpers import ensure_wordlist_local
from .nuclei_helpers import ensure_nuclei_templates_local
from .performance import FlowPerformanceTracker, CommandPerformanceTracker
from .workspace_utils import setup_scan_workspace, setup_scan_directory
from .user_logger import user_log
from . import config_parser
__all__ = [
@@ -31,6 +32,8 @@ __all__ = [
# 性能监控
'FlowPerformanceTracker', # Flow 性能追踪器(含系统资源采样)
'CommandPerformanceTracker', # 命令性能追踪器
# 扫描日志
'user_log', # 用户可见扫描日志记录
# 配置解析
'config_parser',
]

View File

@@ -48,7 +48,7 @@ ENABLE_COMMAND_LOGGING = getattr(settings, 'ENABLE_COMMAND_LOGGING', True)
# 动态并发控制阈值(可在 Django settings 中覆盖)
SCAN_CPU_HIGH = getattr(settings, 'SCAN_CPU_HIGH', 90.0) # CPU 高水位(百分比)
SCAN_MEM_HIGH = getattr(settings, 'SCAN_MEM_HIGH', 80.0) # 内存高水位(百分比)
SCAN_LOAD_CHECK_INTERVAL = getattr(settings, 'SCAN_LOAD_CHECK_INTERVAL', 30) # 负载检查间隔(秒)
SCAN_LOAD_CHECK_INTERVAL = getattr(settings, 'SCAN_LOAD_CHECK_INTERVAL', 180) # 负载检查间隔(秒)
SCAN_COMMAND_STARTUP_DELAY = getattr(settings, 'SCAN_COMMAND_STARTUP_DELAY', 5) # 命令启动前等待(秒)
_ACTIVE_COMMANDS = 0
@@ -74,7 +74,7 @@ def _wait_for_system_load() -> None:
return
logger.info(
"系统负载较高,暂缓启动: cpu=%.1f%% (阈值 %.1f%%), mem=%.1f%% (阈值 %.1f%%)",
"系统负载较高,任务将排队执行防止oom: cpu=%.1f%% (阈值 %.1f%%), mem=%.1f%% (阈值 %.1f%%)",
cpu,
SCAN_CPU_HIGH,
mem,

View File

@@ -0,0 +1,56 @@
"""
扫描日志记录器
提供统一的日志记录接口,用于在 Flow 中记录用户可见的扫描进度日志。
特性:
- 简单的函数式 API
- 只写入数据库ScanLog 表),不写 Python logging
- 错误容忍(数据库失败不影响扫描执行)
职责分离:
- user_log: 用户可见日志(写数据库,前端展示)
- logger: 开发者日志(写日志文件/控制台,调试用)
使用示例:
from apps.scan.utils import user_log
# 用户日志(写数据库)
user_log(scan_id, "port_scan", "Starting port scan")
user_log(scan_id, "port_scan", "naabu completed: found 120 ports")
# 开发者日志(写日志文件)
logger.info("✓ 工具 %s 执行完成 - 记录数: %d", tool_name, count)
"""
import logging
from django.db import DatabaseError
logger = logging.getLogger(__name__)
def user_log(scan_id: int, stage: str, message: str, level: str = "info"):
"""
记录用户可见的扫描日志(只写数据库)
Args:
scan_id: 扫描任务 ID
stage: 阶段名称,如 "port_scan", "site_scan"
message: 日志消息
level: 日志级别,默认 "info",可选 "warning", "error"
数据库 content 格式: "[{stage}] {message}"
"""
formatted = f"[{stage}] {message}"
try:
from apps.scan.models import ScanLog
ScanLog.objects.create(
scan_id=scan_id,
level=level,
content=formatted
)
except DatabaseError as e:
logger.error("ScanLog write failed - scan_id=%s, error=%s", scan_id, e)
except Exception as e:
logger.error("ScanLog write unexpected error - scan_id=%s, error=%s", scan_id, e)

View File

@@ -2,8 +2,10 @@
from .scan_views import ScanViewSet
from .scheduled_scan_views import ScheduledScanViewSet
from .scan_log_views import ScanLogListView
__all__ = [
'ScanViewSet',
'ScheduledScanViewSet',
'ScanLogListView',
]

View File

@@ -0,0 +1,56 @@
"""
扫描日志 API
提供扫描日志查询接口,支持游标分页用于增量轮询。
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from apps.scan.models import ScanLog
from apps.scan.serializers import ScanLogSerializer
class ScanLogListView(APIView):
"""
GET /scans/{scan_id}/logs/
游标分页 API用于增量查询日志
查询参数:
- afterId: 只返回此 ID 之后的日志(用于增量轮询,避免时间戳重复导致的重复日志)
- limit: 返回数量限制(默认 200最大 1000
返回:
- results: 日志列表
- hasMore: 是否还有更多日志
"""
def get(self, request, scan_id: int):
# 参数解析
after_id = request.query_params.get('afterId')
try:
limit = min(int(request.query_params.get('limit', 200)), 1000)
except (ValueError, TypeError):
limit = 200
# 查询日志(按 ID 排序ID 是自增的,保证顺序一致)
queryset = ScanLog.objects.filter(scan_id=scan_id).order_by('id')
# 游标过滤(使用 ID 而非时间戳,避免同一时间戳多条日志导致重复)
if after_id:
try:
queryset = queryset.filter(id__gt=int(after_id))
except (ValueError, TypeError):
pass
# 限制返回数量(多取一条用于判断 hasMore
logs = list(queryset[:limit + 1])
has_more = len(logs) > limit
if has_more:
logs = logs[:limit]
return Response({
'results': ScanLogSerializer(logs, many=True).data,
'hasMore': has_more,
})

View File

@@ -636,7 +636,7 @@ class TestDataGenerator:
cur.execute("""
INSERT INTO scan (
target_id, engine_ids, engine_names, merged_configuration, status, worker_id, progress, current_stage,
target_id, engine_ids, engine_names, yaml_configuration, status, worker_id, progress, current_stage,
results_dir, error_message, container_ids, stage_progress,
cached_subdomains_count, cached_websites_count, cached_endpoints_count,
cached_ips_count, cached_directories_count, cached_vulns_total,
@@ -749,7 +749,7 @@ class TestDataGenerator:
cur.execute("""
INSERT INTO scheduled_scan (
name, engine_ids, engine_names, merged_configuration, organization_id, target_id, cron_expression, is_enabled,
name, engine_ids, engine_names, yaml_configuration, organization_id, target_id, cron_expression, is_enabled,
run_count, last_run_time, next_run_time, created_at, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() - INTERVAL '%s days', NOW())
ON CONFLICT DO NOTHING

View File

@@ -38,6 +38,8 @@ http {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s; # 5分钟支持大数据量导出
proxy_send_timeout 300s;
proxy_pass http://backend;
}

View File

@@ -102,7 +102,11 @@ RUN pip install uv --break-system-packages && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# 6. 复制后端代码
# 6. 设置 Prefect 配置目录(避免 home 目录不存在的警告)
ENV PREFECT_HOME=/app/.prefect
RUN mkdir -p /app/.prefect
# 7. 复制后端代码
COPY backend /app/backend
ENV PYTHONPATH=/app/backend

View File

@@ -0,0 +1,318 @@
"use client"
import React, { useMemo, useCallback } from "react"
import { Play, Server, Settings, Zap } from "lucide-react"
import { useTranslations } from "next-intl"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import { CAPABILITY_CONFIG, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
import type { ScanEngine } from "@/types/engine.types"
export interface EnginePreset {
id: string
label: string
description: string
icon: React.ComponentType<{ className?: string }>
engineIds: number[]
}
interface EnginePresetSelectorProps {
engines: ScanEngine[]
selectedEngineIds: number[]
selectedPresetId: string | null
onPresetChange: (presetId: string | null) => void
onEngineIdsChange: (engineIds: number[]) => void
onConfigurationChange: (config: string) => void
disabled?: boolean
className?: string
}
export function EnginePresetSelector({
engines,
selectedEngineIds,
selectedPresetId,
onPresetChange,
onEngineIdsChange,
onConfigurationChange,
disabled = false,
className,
}: EnginePresetSelectorProps) {
const t = useTranslations("scan.initiate")
const tStages = useTranslations("scan.progress.stages")
// Preset definitions with precise engine filtering
const enginePresets = useMemo(() => {
if (!engines?.length) return []
// Categorize engines by their capabilities
const fullScanEngines: number[] = []
const reconEngines: number[] = []
const vulnEngines: number[] = []
engines.forEach(e => {
const caps = parseEngineCapabilities(e.configuration || "")
const hasRecon = caps.includes("subdomain_discovery") || caps.includes("port_scan") || caps.includes("site_scan") || caps.includes("directory_scan") || caps.includes("url_fetch")
const hasVuln = caps.includes("vuln_scan")
if (hasRecon && hasVuln) {
// Full capability engine - only for full scan
fullScanEngines.push(e.id)
} else if (hasRecon && !hasVuln) {
// Recon only engine
reconEngines.push(e.id)
} else if (hasVuln && !hasRecon) {
// Vuln only engine
vulnEngines.push(e.id)
}
})
return [
{
id: "full",
label: t("presets.fullScan"),
description: t("presets.fullScanDesc"),
icon: Zap,
engineIds: fullScanEngines,
},
{
id: "recon",
label: t("presets.recon"),
description: t("presets.reconDesc"),
icon: Server,
engineIds: reconEngines,
},
{
id: "vuln",
label: t("presets.vulnScan"),
description: t("presets.vulnScanDesc"),
icon: Play,
engineIds: vulnEngines,
},
{
id: "custom",
label: t("presets.custom"),
description: t("presets.customDesc"),
icon: Settings,
engineIds: [],
},
]
}, [engines, t])
const selectedEngines = useMemo(() => {
if (!selectedEngineIds.length || !engines) return []
return engines.filter((e) => selectedEngineIds.includes(e.id))
}, [selectedEngineIds, engines])
const selectedCapabilities = useMemo(() => {
if (!selectedEngines.length) return []
const allCaps = new Set<string>()
selectedEngines.forEach((engine) => {
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
})
return Array.from(allCaps)
}, [selectedEngines])
// Get currently selected preset details
const selectedPreset = useMemo(() => {
return enginePresets.find(p => p.id === selectedPresetId)
}, [enginePresets, selectedPresetId])
// Get engines for the selected preset
const presetEngines = useMemo(() => {
if (!selectedPreset || selectedPreset.id === "custom") return []
return engines?.filter(e => selectedPreset.engineIds.includes(e.id)) || []
}, [selectedPreset, engines])
// Update configuration when engines change
const updateConfigurationFromEngines = useCallback((engineIds: number[]) => {
if (!engines) return
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
onConfigurationChange(mergedConfig)
}, [engines, onConfigurationChange])
const handlePresetSelect = useCallback((preset: EnginePreset) => {
onPresetChange(preset.id)
if (preset.id !== "custom") {
onEngineIdsChange(preset.engineIds)
updateConfigurationFromEngines(preset.engineIds)
} else {
// Custom mode - keep current selection or clear
if (selectedEngineIds.length === 0) {
onConfigurationChange("")
}
}
}, [onPresetChange, onEngineIdsChange, updateConfigurationFromEngines, selectedEngineIds.length, onConfigurationChange])
const handleEngineToggle = useCallback((engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) {
newEngineIds = [...selectedEngineIds, engineId]
} else {
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
}
onEngineIdsChange(newEngineIds)
updateConfigurationFromEngines(newEngineIds)
}, [selectedEngineIds, onEngineIdsChange, updateConfigurationFromEngines])
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="flex-1 overflow-y-auto p-6">
{/* Compact preset cards */}
<div className="grid grid-cols-4 gap-3 mb-4">
{enginePresets.map((preset) => {
const isActive = selectedPresetId === preset.id
const PresetIcon = preset.icon
const matchedEngines = preset.id === "custom"
? []
: engines?.filter(e => preset.engineIds.includes(e.id)) || []
return (
<button
key={preset.id}
type="button"
onClick={() => handlePresetSelect(preset)}
disabled={disabled}
className={cn(
"flex flex-col items-center p-3 rounded-lg border-2 text-center transition-all",
isActive
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/30",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg mb-2",
isActive ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
<PresetIcon className="h-5 w-5" />
</div>
<span className="text-sm font-medium">{preset.label}</span>
{preset.id !== "custom" && (
<span className="text-xs text-muted-foreground mt-1">
{matchedEngines.length} {t("presets.enginesCount")}
</span>
)}
</button>
)
})}
</div>
{/* Selected preset details */}
{selectedPresetId && selectedPresetId !== "custom" && (
<div className="border rounded-lg p-4 bg-muted/10">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-medium">{selectedPreset?.label}</h3>
<p className="text-sm text-muted-foreground mt-1">{selectedPreset?.description}</p>
</div>
</div>
{/* Capabilities */}
<div className="mb-4">
<h4 className="text-xs font-medium text-muted-foreground mb-2">{t("presets.capabilities")}</h4>
<div className="flex flex-wrap gap-1.5">
{selectedCapabilities.map((capKey) => {
const config = CAPABILITY_CONFIG[capKey]
return (
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
{tStages(capKey)}
</Badge>
)
})}
</div>
</div>
{/* Engines list */}
<div>
<h4 className="text-xs font-medium text-muted-foreground mb-2">{t("presets.usedEngines")}</h4>
<div className="flex flex-wrap gap-2">
{presetEngines.map((engine) => (
<span key={engine.id} className="text-sm px-3 py-1.5 bg-background rounded-md border">
{engine.name}
</span>
))}
</div>
</div>
</div>
)}
{/* Custom mode engine selection */}
{selectedPresetId === "custom" && (
<div className="border rounded-lg p-4 bg-muted/10">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-medium">{selectedPreset?.label}</h3>
<p className="text-sm text-muted-foreground mt-1">{selectedPreset?.description}</p>
</div>
</div>
{/* Capabilities - dynamically calculated from selected engines */}
<div className="mb-4">
<h4 className="text-xs font-medium text-muted-foreground mb-2">{t("presets.capabilities")}</h4>
<div className="flex flex-wrap gap-1.5">
{selectedCapabilities.length > 0 ? (
selectedCapabilities.map((capKey) => {
const config = CAPABILITY_CONFIG[capKey]
return (
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
{tStages(capKey)}
</Badge>
)
})
) : (
<span className="text-xs text-muted-foreground">{t("presets.noCapabilities")}</span>
)}
</div>
</div>
{/* Engines list - selectable */}
<div>
<h4 className="text-xs font-medium text-muted-foreground mb-2">{t("presets.usedEngines")}</h4>
<div className="flex flex-wrap gap-2">
{engines?.map((engine) => {
const isSelected = selectedEngineIds.includes(engine.id)
return (
<label
key={engine.id}
htmlFor={`preset-engine-${engine.id}`}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer transition-all border",
isSelected
? "bg-primary/10 border-primary/30"
: "hover:bg-muted/50 border-border",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<Checkbox
id={`preset-engine-${engine.id}`}
checked={isSelected}
onCheckedChange={(checked) => {
handleEngineToggle(engine.id, checked as boolean)
}}
disabled={disabled}
className="h-4 w-4"
/>
<span className="text-sm">{engine.name}</span>
</label>
)
})}
</div>
</div>
</div>
)}
{/* Empty state */}
{!selectedPresetId && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Server className="h-12 w-12 mb-4 opacity-50" />
<p className="text-sm">{t("presets.selectHint")}</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import React, { useState, useMemo, useCallback } from "react"
import { Play, Settings2 } from "lucide-react"
import { Play, Server, Settings, ChevronLeft, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
@@ -9,7 +9,6 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
@@ -23,12 +22,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { LoadingSpinner } from "@/components/loading-spinner"
import { cn } from "@/lib/utils"
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
import { EnginePresetSelector } from "./engine-preset-selector"
import { ScanConfigEditor } from "./scan-config-editor"
import type { Organization } from "@/types/organization.types"
@@ -57,79 +53,63 @@ export function InitiateScanDialog({
}: InitiateScanDialogProps) {
const t = useTranslations("scan.initiate")
const tToast = useTranslations("toast")
const tCommon = useTranslations("common.actions")
const [step, setStep] = useState(1)
const [selectedEngineIds, setSelectedEngineIds] = useState<number[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null)
// Configuration state management
const [configuration, setConfiguration] = useState("")
const [isConfigEdited, setIsConfigEdited] = useState(false)
const [isYamlValid, setIsYamlValid] = useState(true)
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false)
const [pendingEngineChange, setPendingEngineChange] = useState<{ engineId: number; checked: boolean } | null>(null)
const [pendingConfigChange, setPendingConfigChange] = useState<string | null>(null)
const { data: engines, isLoading, error } = useEngines()
const { data: engines } = useEngines()
const steps = [
{ id: 1, title: t("steps.selectEngine"), icon: Server },
{ id: 2, title: t("steps.editConfig"), icon: Settings },
]
const selectedEngines = useMemo(() => {
if (!selectedEngineIds.length || !engines) return []
return engines.filter((e) => selectedEngineIds.includes(e.id))
}, [selectedEngineIds, engines])
const selectedCapabilities = useMemo(() => {
if (!selectedEngines.length) return []
const allCaps = new Set<string>()
selectedEngines.forEach((engine) => {
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
})
return Array.from(allCaps)
}, [selectedEngines])
// Update configuration when engines change (if not manually edited)
const updateConfigurationFromEngines = useCallback((engineIds: number[]) => {
if (!engines) return
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
setConfiguration(mergedConfig)
}, [engines])
const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) {
newEngineIds = [...selectedEngineIds, engineId]
} else {
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
}
setSelectedEngineIds(newEngineIds)
updateConfigurationFromEngines(newEngineIds)
setIsConfigEdited(false)
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
if (isConfigEdited) {
// User has edited config, show confirmation
setPendingEngineChange({ engineId, checked })
// Handle configuration change from preset selector (may need confirmation)
const handlePresetConfigChange = useCallback((value: string) => {
if (isConfigEdited && configuration !== value) {
setPendingConfigChange(value)
setShowOverwriteConfirm(true)
} else {
applyEngineChange(engineId, checked)
setConfiguration(value)
setIsConfigEdited(false)
}
}
}, [isConfigEdited, configuration])
// Handle manual config editing
const handleManualConfigChange = useCallback((value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
}, [])
const handleEngineIdsChange = useCallback((engineIds: number[]) => {
setSelectedEngineIds(engineIds)
}, [])
const handleOverwriteConfirm = () => {
if (pendingEngineChange) {
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
if (pendingConfigChange !== null) {
setConfiguration(pendingConfigChange)
setIsConfigEdited(false)
}
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
setPendingConfigChange(null)
}
const handleOverwriteCancel = () => {
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleConfigurationChange = (value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
setPendingConfigChange(null)
}
const handleYamlValidationChange = (isValid: boolean) => {
@@ -184,6 +164,8 @@ export function InitiateScanDialog({
if (!isSubmitting) {
onOpenChange(newOpen)
if (!newOpen) {
setStep(1)
setSelectedPresetId(null)
setSelectedEngineIds([])
setConfiguration("")
setIsConfigEdited(false)
@@ -191,172 +173,96 @@ export function InitiateScanDialog({
}
}
const canProceedToStep2 = selectedPresetId !== null && selectedEngineIds.length > 0
const canSubmit = selectedEngineIds.length > 0 && configuration.trim().length > 0 && isYamlValid
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[90vw] sm:max-w-[900px] p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<Play className="h-5 w-5" />
{t("title")}
<span className="text-sm font-normal text-muted-foreground">
{targetName ? (
<>
{t("targetDesc")} <span className="font-medium text-foreground">{targetName}</span> {t("selectEngine")}
</>
) : (
<>
{t("orgDesc")} <span className="font-medium text-foreground">{organization?.name}</span> {t("selectEngine")}
</>
)}
</span>
</DialogTitle>
</DialogHeader>
<div className="flex border-t h-[480px]">
{/* Left side engine list */}
<div className="w-[260px] border-r flex flex-col shrink-0">
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
<h3 className="text-sm font-medium">
{t("selectEngineTitle")}
{selectedEngineIds.length > 0 && (
<span className="text-xs text-muted-foreground font-normal ml-2">
{t("selectedCount", { count: selectedEngineIds.length })}
</span>
)}
</h3>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="ml-2 text-sm text-muted-foreground">{t("loading")}</span>
</div>
) : error ? (
<div className="py-8 text-center text-sm text-destructive">{t("loadFailed")}</div>
) : !engines?.length ? (
<div className="py-8 text-center text-sm text-muted-foreground">{t("noEngines")}</div>
<div className="flex items-center justify-between">
<div>
<DialogTitle className="flex items-center gap-2">
<Play className="h-5 w-5" />
{t("title")}
</DialogTitle>
<DialogDescription className="mt-1">
{targetName ? (
<>{t("targetDesc")} <span className="font-medium text-foreground">{targetName}</span></>
) : (
<div className="space-y-1">
{engines.map((engine) => {
const capabilities = parseEngineCapabilities(engine.configuration || "")
const EngineIcon = getEngineIcon(capabilities)
const primaryCap = capabilities[0]
const iconConfig = primaryCap ? CAPABILITY_CONFIG[primaryCap] : null
const isSelected = selectedEngineIds.includes(engine.id)
return (
<label
key={engine.id}
htmlFor={`engine-${engine.id}`}
className={cn(
"flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all",
isSelected
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted/50 border border-transparent"
)}
>
<Checkbox
id={`engine-${engine.id}`}
checked={isSelected}
onCheckedChange={(checked) => handleEngineToggle(engine.id, checked as boolean)}
disabled={isSubmitting}
/>
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md shrink-0",
iconConfig?.color || "bg-muted text-muted-foreground"
)}
>
<EngineIcon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{engine.name}</div>
<div className="text-xs text-muted-foreground">
{capabilities.length > 0 ? t("capabilities", { count: capabilities.length }) : t("noConfig")}
</div>
</div>
</label>
)
})}
</div>
<>{t("orgDesc")} <span className="font-medium text-foreground">{organization?.name}</span></>
)}
</div>
</DialogDescription>
</div>
{/* Step indicator */}
<div className="text-sm text-muted-foreground mr-8">
{t("stepIndicator", { current: step, total: steps.length })}
</div>
</div>
</DialogHeader>
{/* Right side engine details */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden w-0">
{selectedEngines.length > 0 ? (
<>
<div className="px-4 py-3 border-b bg-muted/30 shrink-0 min-w-0">
<div className="flex items-center gap-2">
<div className="flex flex-wrap gap-1.5 flex-1">
{selectedCapabilities.map((capKey) => {
const config = CAPABILITY_CONFIG[capKey]
return (
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
{config?.label || capKey}
</Badge>
)
})}
</div>
{isConfigEdited && (
<Badge variant="outline" className="text-xs shrink-0">
{t("configEdited")}
</Badge>
)}
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden p-4 min-w-0">
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0 min-w-0">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div>
</>
<div className="border-t h-[480px] overflow-hidden">
{/* Step 1: Select preset/engines */}
{step === 1 && engines && (
<EnginePresetSelector
engines={engines}
selectedEngineIds={selectedEngineIds}
selectedPresetId={selectedPresetId}
onPresetChange={setSelectedPresetId}
onEngineIdsChange={handleEngineIdsChange}
onConfigurationChange={handlePresetConfigChange}
disabled={isSubmitting}
/>
)}
{/* Step 2: Edit configuration */}
{step === 2 && (
<ScanConfigEditor
configuration={configuration}
onChange={handleManualConfigChange}
onValidationChange={handleYamlValidationChange}
selectedEngines={selectedEngines}
isConfigEdited={isConfigEdited}
disabled={isSubmitting}
/>
)}
</div>
<div className="px-6 py-4 border-t flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{step === 1 && selectedEngineIds.length > 0 && (
<span className="text-primary">{t("selectedCount", { count: selectedEngineIds.length })}</span>
)}
</div>
<div className="flex gap-2">
{step > 1 && (
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={isSubmitting}>
<ChevronLeft className="h-4 w-4 mr-1" />
{t("back")}
</Button>
)}
{step === 1 ? (
<Button onClick={() => setStep(2)} disabled={!canProceedToStep2}>
{t("next")}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
<h3 className="text-sm font-medium">{t("configTitle")}</h3>
</div>
<div className="flex-1 flex flex-col overflow-hidden p-4">
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div>
</div>
<Button onClick={handleInitiate} disabled={!canSubmit || isSubmitting}>
{isSubmitting ? (
<>
<LoadingSpinner />
{t("initiating")}
</>
) : (
<>
<Play className="h-4 w-4" />
{t("startScan")}
</>
)}
</Button>
)}
</div>
</div>
<DialogFooter className="px-6 py-4 border-t">
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleInitiate} disabled={selectedEngineIds.length === 0 || !configuration.trim() || !isYamlValid || isSubmitting}>
{isSubmitting ? (
<>
<LoadingSpinner />
{t("initiating")}
</>
) : (
<>
<Play className="h-4 w-4" />
{t("startScan")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
{/* Overwrite confirmation dialog */}

View File

@@ -23,17 +23,15 @@ import {
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { LoadingSpinner } from "@/components/loading-spinner"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { Zap, Settings2, AlertCircle, ChevronRight, ChevronLeft, Target, Server } from "lucide-react"
import { Zap, AlertCircle, ChevronRight, ChevronLeft, Target, Server, Settings } from "lucide-react"
import { quickScan } from "@/services/scan.service"
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
import { TargetValidator } from "@/lib/target-validator"
import { useEngines } from "@/hooks/use-engines"
import { EnginePresetSelector } from "./engine-preset-selector"
import { ScanConfigEditor } from "./scan-config-editor"
interface QuickScanDialogProps {
trigger?: React.ReactNode
@@ -47,15 +45,16 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
const [targetInput, setTargetInput] = React.useState("")
const [selectedEngineIds, setSelectedEngineIds] = React.useState<number[]>([])
const [selectedPresetId, setSelectedPresetId] = React.useState<string | null>(null)
// Configuration state management
const [configuration, setConfiguration] = React.useState("")
const [isConfigEdited, setIsConfigEdited] = React.useState(false)
const [isYamlValid, setIsYamlValid] = React.useState(true)
const [showOverwriteConfirm, setShowOverwriteConfirm] = React.useState(false)
const [pendingEngineChange, setPendingEngineChange] = React.useState<{ engineId: number; checked: boolean } | null>(null)
const [pendingConfigChange, setPendingConfigChange] = React.useState<string | null>(null)
const { data: engines, isLoading, error } = useEngines()
const { data: engines } = useEngines()
const lineNumbersRef = React.useRef<HTMLDivElement | null>(null)
@@ -79,26 +78,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
return engines.filter(e => selectedEngineIds.includes(e.id))
}, [selectedEngineIds, engines])
const selectedCapabilities = React.useMemo(() => {
if (!selectedEngines.length) return []
const allCaps = new Set<string>()
selectedEngines.forEach((engine) => {
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
})
return Array.from(allCaps)
}, [selectedEngines])
// Update configuration when engines change (if not manually edited)
const updateConfigurationFromEngines = React.useCallback((engineIds: number[]) => {
if (!engines) return
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
setConfiguration(mergedConfig)
}, [engines])
const resetForm = () => {
setTargetInput("")
setSelectedEngineIds([])
setSelectedPresetId(null)
setConfiguration("")
setIsConfigEdited(false)
setStep(1)
@@ -109,44 +92,39 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
if (!isOpen) resetForm()
}
const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) {
newEngineIds = [...selectedEngineIds, engineId]
} else {
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
}
setSelectedEngineIds(newEngineIds)
updateConfigurationFromEngines(newEngineIds)
setIsConfigEdited(false)
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
if (isConfigEdited) {
// User has edited config, show confirmation
setPendingEngineChange({ engineId, checked })
// Handle configuration change from preset selector (may need confirmation)
const handlePresetConfigChange = React.useCallback((value: string) => {
if (isConfigEdited && configuration !== value) {
setPendingConfigChange(value)
setShowOverwriteConfirm(true)
} else {
applyEngineChange(engineId, checked)
setConfiguration(value)
setIsConfigEdited(false)
}
}
}, [isConfigEdited, configuration])
// Handle manual config editing
const handleManualConfigChange = React.useCallback((value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
}, [])
const handleEngineIdsChange = React.useCallback((engineIds: number[]) => {
setSelectedEngineIds(engineIds)
}, [])
const handleOverwriteConfirm = () => {
if (pendingEngineChange) {
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
if (pendingConfigChange !== null) {
setConfiguration(pendingConfigChange)
setIsConfigEdited(false)
}
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
setPendingConfigChange(null)
}
const handleOverwriteCancel = () => {
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleConfigurationChange = (value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
setPendingConfigChange(null)
}
const handleYamlValidationChange = (isValid: boolean) => {
@@ -154,10 +132,12 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
}
const canProceedToStep2 = validInputs.length > 0 && !hasErrors
const canProceedToStep3 = selectedPresetId !== null && selectedEngineIds.length > 0
const canSubmit = selectedEngineIds.length > 0 && configuration.trim().length > 0 && isYamlValid
const handleNext = () => {
if (step === 1 && canProceedToStep2) setStep(2)
else if (step === 2 && canProceedToStep3) setStep(3)
}
const handleBack = () => {
@@ -167,6 +147,7 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
const steps = [
{ id: 1, title: t("step1Title"), icon: Target },
{ id: 2, title: t("step2Title"), icon: Server },
{ id: 3, title: t("step3Title"), icon: Settings },
]
const handleSubmit = async () => {
@@ -243,36 +224,8 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
</DialogDescription>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2 mr-8">
{steps.map((s, index) => (
<React.Fragment key={s.id}>
<button
type="button"
onClick={() => {
if (s.id < step) setStep(s.id)
else if (s.id === 2 && canProceedToStep2) setStep(2)
}}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-colors",
step === s.id
? "bg-primary text-primary-foreground"
: step > s.id
? "bg-primary/20 text-primary cursor-pointer hover:bg-primary/30"
: "bg-muted text-muted-foreground"
)}
disabled={s.id > step && !(s.id === 2 && canProceedToStep2)}
>
<s.icon className="h-4 w-4" />
<span className="hidden sm:inline">{s.title}</span>
</button>
{index < steps.length - 1 && (
<div className={cn(
"w-8 h-[2px]",
step > s.id ? "bg-primary/50" : "bg-muted"
)} />
)}
</React.Fragment>
))}
<div className="text-sm text-muted-foreground mr-8">
{t("stepIndicator", { current: step, total: steps.length })}
</div>
</div>
</DialogHeader>
@@ -323,136 +276,30 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
</div>
)}
{/* Step 2: Select engines */}
{step === 2 && (
<div className="flex h-full">
<div className="w-[320px] border-r flex flex-col shrink-0">
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
<h3 className="text-sm font-medium">{t("selectEngine")}</h3>
{selectedEngineIds.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{t("selectedCount", { count: selectedEngineIds.length })}
</p>
)}
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="ml-2 text-sm text-muted-foreground">{t("loading")}</span>
</div>
) : error ? (
<div className="py-8 text-center text-sm text-destructive">{t("loadFailed")}</div>
) : !engines?.length ? (
<div className="py-8 text-center text-sm text-muted-foreground">{t("noEngines")}</div>
) : (
<div className="space-y-1">
{engines.map((engine) => {
const capabilities = parseEngineCapabilities(engine.configuration || "")
const EngineIcon = getEngineIcon(capabilities)
const primaryCap = capabilities[0]
const iconConfig = primaryCap ? CAPABILITY_CONFIG[primaryCap] : null
const isSelected = selectedEngineIds.includes(engine.id)
return (
<label
key={engine.id}
htmlFor={`quick-engine-${engine.id}`}
className={cn(
"flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all",
isSelected
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted/50 border border-transparent"
)}
>
<Checkbox
id={`quick-engine-${engine.id}`}
checked={isSelected}
onCheckedChange={(checked) => handleEngineToggle(engine.id, checked as boolean)}
disabled={isSubmitting}
/>
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md shrink-0",
iconConfig?.color || "bg-muted text-muted-foreground"
)}
>
<EngineIcon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{engine.name}</div>
<div className="text-xs text-muted-foreground">
{capabilities.length > 0 ? t("capabilities", { count: capabilities.length }) : t("noConfig")}
</div>
</div>
</label>
)
})}
</div>
)}
</div>
</div>
</div>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{selectedEngines.length > 0 ? (
<>
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium truncate">
{selectedEngines.map((e) => e.name).join(", ")}
</h3>
{isConfigEdited && (
<Badge variant="outline" className="ml-auto text-xs">
{t("configEdited")}
</Badge>
)}
</div>
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-3">
{selectedCapabilities.length > 0 && (
<div className="flex flex-wrap gap-1.5 shrink-0">
{selectedCapabilities.map((capKey) => {
const config = CAPABILITY_CONFIG[capKey]
return (
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
{config?.label || capKey}
</Badge>
)
})}
</div>
)}
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div>
</>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{t("configTitle")}</h3>
</div>
<div className="flex-1 flex flex-col overflow-hidden p-4">
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div>
</div>
)}
</div>
</div>
{/* Step 2: Select preset/engines */}
{step === 2 && engines && (
<EnginePresetSelector
engines={engines}
selectedEngineIds={selectedEngineIds}
selectedPresetId={selectedPresetId}
onPresetChange={setSelectedPresetId}
onEngineIdsChange={handleEngineIdsChange}
onConfigurationChange={handlePresetConfigChange}
disabled={isSubmitting}
/>
)}
{/* Step 3: Edit configuration */}
{step === 3 && (
<ScanConfigEditor
configuration={configuration}
onChange={handleManualConfigChange}
onValidationChange={handleYamlValidationChange}
selectedEngines={selectedEngines}
isConfigEdited={isConfigEdited}
disabled={isSubmitting}
/>
)}
</div>
<DialogFooter className="px-4 py-4 border-t !flex !items-center !justify-between">
@@ -474,10 +321,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
{t("back")}
</Button>
)}
{step === 1 ? (
{step < 3 ? (
<Button
onClick={handleNext}
disabled={!canProceedToStep2}
disabled={step === 1 ? !canProceedToStep2 : !canProceedToStep3}
>
{t("next")}
<ChevronRight className="h-4 w-4 ml-1" />

View File

@@ -0,0 +1,86 @@
"use client"
import React, { useMemo } from "react"
import { useTranslations } from "next-intl"
import { Badge } from "@/components/ui/badge"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { cn } from "@/lib/utils"
import { CAPABILITY_CONFIG, parseEngineCapabilities } from "@/lib/engine-config"
import type { ScanEngine } from "@/types/engine.types"
interface ScanConfigEditorProps {
configuration: string
onChange: (value: string) => void
onValidationChange?: (isValid: boolean) => void
selectedEngines?: ScanEngine[]
selectedCapabilities?: string[]
isConfigEdited?: boolean
disabled?: boolean
showCapabilities?: boolean
className?: string
}
export function ScanConfigEditor({
configuration,
onChange,
onValidationChange,
selectedEngines = [],
selectedCapabilities: propCapabilities,
isConfigEdited = false,
disabled = false,
showCapabilities = true,
className,
}: ScanConfigEditorProps) {
const t = useTranslations("scan.initiate")
const tStages = useTranslations("scan.progress.stages")
// Calculate capabilities from selected engines if not provided
const capabilities = useMemo(() => {
if (propCapabilities) return propCapabilities
if (!selectedEngines.length) return []
const allCaps = new Set<string>()
selectedEngines.forEach((engine) => {
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
})
return Array.from(allCaps)
}, [selectedEngines, propCapabilities])
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Capabilities header */}
{showCapabilities && (
<div className="px-4 py-2 border-b bg-muted/30 flex items-center gap-2 shrink-0">
{capabilities.length > 0 && (
<div className="flex flex-wrap gap-1">
{capabilities.map((capKey) => {
const config = CAPABILITY_CONFIG[capKey]
return (
<Badge key={capKey} variant="outline" className={cn("text-xs py-0", config?.color)}>
{tStages(capKey)}
</Badge>
)
})}
</div>
)}
{isConfigEdited && (
<Badge variant="outline" className="ml-auto text-xs">
{t("configEdited")}
</Badge>
)}
</div>
)}
{/* YAML Editor */}
<div className="flex-1 overflow-hidden">
<YamlEditor
value={configuration}
onChange={onChange}
disabled={disabled}
onValidationChange={onValidationChange}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
"use client"
import { useEffect, useRef, useMemo } from "react"
import type { ScanLog } from "@/services/scan.service"
interface ScanLogListProps {
logs: ScanLog[]
loading?: boolean
}
/**
* 格式化时间为 HH:mm:ss
*/
function formatTime(isoString: string): string {
try {
const date = new Date(isoString)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
} catch {
return isoString
}
}
/**
* HTML 转义,防止 XSS
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 扫描日志列表组件
*
* 特性:
* - 预渲染 HTML 字符串,减少 DOM 节点提升性能
* - 颜色区分info=默认, warning=黄色, error=红色
* - 自动滚动到底部
*/
export function ScanLogList({ logs, loading }: ScanLogListProps) {
const containerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true) // 跟踪用户是否在底部
// 预渲染 HTML 字符串
const htmlContent = useMemo(() => {
if (logs.length === 0) return ''
return logs.map(log => {
const time = formatTime(log.createdAt)
const content = escapeHtml(log.content)
const levelStyle = log.level === 'error'
? 'color:#ef4444'
: log.level === 'warning'
? 'color:#eab308'
: ''
return `<div style="line-height:1.625;word-break:break-all;${levelStyle}"><span style="color:#6b7280">${time}</span> ${content}</div>`
}).join('')
}, [logs])
// 监听滚动事件,检测用户是否在底部
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
// 允许 30px 的容差,认为在底部附近
isAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 30
}
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [])
// 只有用户在底部时才自动滚动
useEffect(() => {
if (containerRef.current && isAtBottomRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [htmlContent])
return (
<div
ref={containerRef}
className="h-[400px] overflow-y-auto font-mono text-[11px] p-3 bg-muted/30 rounded-lg"
>
{logs.length === 0 && !loading && (
<div className="text-muted-foreground text-center py-8">
</div>
)}
{htmlContent && (
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
)}
{loading && logs.length === 0 && (
<div className="text-muted-foreground text-center py-8">
...
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import * as React from "react"
import { useState } from "react"
import {
Dialog,
DialogContent,
@@ -9,6 +10,7 @@ import {
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
IconCircleCheck,
IconLoader,
@@ -19,6 +21,8 @@ import {
import { cn } from "@/lib/utils"
import { useTranslations, useLocale } from "next-intl"
import type { ScanStage, ScanRecord, StageProgress, StageStatus } from "@/types/scan.types"
import { useScanLogs } from "@/hooks/use-scan-logs"
import { ScanLogList } from "./scan-log-list"
/**
* Scan stage details
@@ -190,12 +194,26 @@ export function ScanProgressDialog({
}: ScanProgressDialogProps) {
const t = useTranslations("scan.progress")
const locale = useLocale()
const [activeTab, setActiveTab] = useState<'stages' | 'logs'>('stages')
// 判断扫描是否正在运行(用于控制轮询)
const isRunning = data?.status === 'running' || data?.status === 'initiated'
// 日志轮询 Hook
const { logs, loading: logsLoading } = useScanLogs({
scanId: data?.id ?? 0,
enabled: open && activeTab === 'logs' && !!data?.id,
pollingInterval: isRunning ? 3000 : 0, // 运行中时 3s 轮询,否则不轮询
})
if (!data) return null
// 固定宽度,切换 Tab 时不变化
const dialogWidth = 'sm:max-w-[600px] sm:min-w-[550px]'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-fit sm:min-w-[450px]">
<DialogContent className={cn(dialogWidth, "transition-all duration-200")}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ScanStatusIcon status={data.status} />
@@ -211,7 +229,7 @@ export function ScanProgressDialog({
</div>
<div className="flex items-start justify-between text-sm gap-4">
<span className="text-muted-foreground shrink-0">{t("engine")}</span>
<div className="grid grid-cols-[repeat(2,auto)] gap-1.5 justify-end">
<div className="flex flex-wrap gap-1.5 justify-end">
{data.engineNames?.length ? (
data.engineNames.map((name) => (
<Badge key={name} variant="secondary" className="text-xs whitespace-nowrap">
@@ -244,37 +262,26 @@ export function ScanProgressDialog({
<Separator />
{/* Total progress */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{t("totalProgress")}</span>
<span className="font-mono text-muted-foreground">{data.progress}%</span>
{/* Tab 切换 */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'stages' | 'logs')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="stages">{t("tab_stages")}</TabsTrigger>
<TabsTrigger value="logs">{t("tab_logs")}</TabsTrigger>
</TabsList>
</Tabs>
{/* Tab 内容 */}
{activeTab === 'stages' ? (
/* Stage list */
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{data.stages.map((stage) => (
<StageRow key={stage.stage} stage={stage} t={t} />
))}
</div>
<div className="h-2 bg-primary/10 rounded-full overflow-hidden border border-border">
<div
className={`h-full transition-all ${
data.status === "completed" ? "bg-[#238636]/80" :
data.status === "failed" ? "bg-[#da3633]/80" :
data.status === "running" ? "bg-[#d29922]/80 progress-striped" :
data.status === "cancelled" ? "bg-[#848d97]/80" :
data.status === "cancelling" ? "bg-[#d29922]/80 progress-striped" :
data.status === "initiated" ? "bg-[#d29922]/80 progress-striped" :
"bg-muted-foreground/80"
}`}
style={{ width: `${data.status === "completed" ? 100 : data.progress}%` }}
/>
</div>
</div>
<Separator />
{/* Stage list */}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{data.stages.map((stage) => (
<StageRow key={stage.stage} stage={stage} t={t} />
))}
</div>
) : (
/* Log list */
<ScanLogList logs={logs} loading={logsLoading} />
)}
</DialogContent>
</Dialog>
)

View File

@@ -24,7 +24,6 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { YamlEditor } from "@/components/ui/yaml-editor"
import {
Command,
CommandEmpty,
@@ -45,6 +44,8 @@ import {
IconClock,
IconInfoCircle,
IconSearch,
IconSettings,
IconCode,
} from "@tabler/icons-react"
import { CronExpressionParser } from "cron-parser"
import cronstrue from "cronstrue/i18n"
@@ -54,11 +55,11 @@ import { useTargets } from "@/hooks/use-targets"
import { useEngines } from "@/hooks/use-engines"
import { useOrganizations } from "@/hooks/use-organizations"
import { useTranslations, useLocale } from "next-intl"
import { mergeEngineConfigurations, CAPABILITY_CONFIG, parseEngineCapabilities } from "@/lib/engine-config"
import type { CreateScheduledScanRequest } from "@/types/scheduled-scan.types"
import type { ScanEngine } from "@/types/engine.types"
import type { Target } from "@/types/target.types"
import type { Organization } from "@/types/organization.types"
import { EnginePresetSelector } from "../engine-preset-selector"
import { ScanConfigEditor } from "../scan-config-editor"
interface CreateScheduledScanDialogProps {
@@ -97,14 +98,16 @@ export function CreateScheduledScanDialog({
const FULL_STEPS = [
{ id: 1, title: t("steps.basicInfo"), icon: IconInfoCircle },
{ id: 2, title: t("steps.scanMode"), icon: IconBuilding },
{ id: 3, title: t("steps.selectTarget"), icon: IconTarget },
{ id: 4, title: t("steps.scheduleSettings"), icon: IconClock },
{ id: 2, title: t("steps.selectTarget"), icon: IconTarget },
{ id: 3, title: t("steps.selectEngine"), icon: IconSettings },
{ id: 4, title: t("steps.editConfig"), icon: IconCode },
{ id: 5, title: t("steps.scheduleSettings"), icon: IconClock },
]
const PRESET_STEPS = [
{ id: 1, title: t("steps.basicInfo"), icon: IconInfoCircle },
{ id: 2, title: t("steps.scheduleSettings"), icon: IconClock },
{ id: 1, title: t("steps.selectEngine"), icon: IconSettings },
{ id: 2, title: t("steps.editConfig"), icon: IconCode },
{ id: 3, title: t("steps.scheduleSettings"), icon: IconClock },
]
const [orgSearchInput, setOrgSearchInput] = React.useState("")
@@ -132,6 +135,7 @@ export function CreateScheduledScanDialog({
const [name, setName] = React.useState("")
const [engineIds, setEngineIds] = React.useState<number[]>([])
const [selectedPresetId, setSelectedPresetId] = React.useState<string | null>(null)
const [selectionMode, setSelectionMode] = React.useState<SelectionMode>("organization")
const [selectedOrgId, setSelectedOrgId] = React.useState<number | null>(null)
const [selectedTargetId, setSelectedTargetId] = React.useState<number | null>(null)
@@ -142,7 +146,7 @@ export function CreateScheduledScanDialog({
const [isConfigEdited, setIsConfigEdited] = React.useState(false)
const [isYamlValid, setIsYamlValid] = React.useState(true)
const [showOverwriteConfirm, setShowOverwriteConfirm] = React.useState(false)
const [pendingEngineChange, setPendingEngineChange] = React.useState<{ engineId: number; checked: boolean } | null>(null)
const [pendingConfigChange, setPendingConfigChange] = React.useState<string | null>(null)
React.useEffect(() => {
if (open) {
@@ -159,7 +163,7 @@ export function CreateScheduledScanDialog({
}, [open, presetOrganizationId, presetOrganizationName, presetTargetId, presetTargetName, t])
const targets: Target[] = targetsData?.targets || []
const engines: ScanEngine[] = enginesData || []
const engines = enginesData || []
const organizations: Organization[] = organizationsData?.organizations || []
// Get selected engines for display
@@ -168,27 +172,10 @@ export function CreateScheduledScanDialog({
return engines.filter(e => engineIds.includes(e.id))
}, [engineIds, engines])
// Get selected capabilities for display
const selectedCapabilities = React.useMemo(() => {
if (!selectedEngines.length) return []
const allCaps = new Set<string>()
selectedEngines.forEach((engine) => {
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
})
return Array.from(allCaps)
}, [selectedEngines])
// Update configuration when engines change (if not manually edited)
const updateConfigurationFromEngines = React.useCallback((newEngineIds: number[]) => {
if (!engines.length) return
const selectedEngs = engines.filter(e => newEngineIds.includes(e.id))
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
setConfiguration(mergedConfig)
}, [engines])
const resetForm = () => {
setName("")
setEngineIds([])
setSelectedPresetId(null)
setSelectionMode("organization")
setSelectedOrgId(null)
setSelectedTargetId(null)
@@ -198,44 +185,39 @@ export function CreateScheduledScanDialog({
resetStep()
}
const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) {
newEngineIds = [...engineIds, engineId]
} else {
newEngineIds = engineIds.filter((id) => id !== engineId)
}
setEngineIds(newEngineIds)
updateConfigurationFromEngines(newEngineIds)
setIsConfigEdited(false)
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
if (isConfigEdited) {
// User has edited config, show confirmation
setPendingEngineChange({ engineId, checked })
// Handle configuration change from preset selector (may need confirmation)
const handlePresetConfigChange = React.useCallback((value: string) => {
if (isConfigEdited && configuration !== value) {
setPendingConfigChange(value)
setShowOverwriteConfirm(true)
} else {
applyEngineChange(engineId, checked)
setConfiguration(value)
setIsConfigEdited(false)
}
}
}, [isConfigEdited, configuration])
// Handle manual config editing
const handleManualConfigChange = React.useCallback((value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
}, [])
const handleEngineIdsChange = React.useCallback((newEngineIds: number[]) => {
setEngineIds(newEngineIds)
}, [])
const handleOverwriteConfirm = () => {
if (pendingEngineChange) {
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
if (pendingConfigChange !== null) {
setConfiguration(pendingConfigChange)
setIsConfigEdited(false)
}
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
setPendingConfigChange(null)
}
const handleOverwriteCancel = () => {
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleConfigurationChange = (value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
setPendingConfigChange(null)
}
const handleYamlValidationChange = (isValid: boolean) => {
@@ -258,13 +240,15 @@ export function CreateScheduledScanDialog({
const validateCurrentStep = (): boolean => {
if (hasPreset) {
switch (currentStep) {
case 1:
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
case 1: // Select engine
if (!selectedPresetId) { toast.error(t("form.scanEngineRequired")); return false }
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
return true
case 2: // Edit config
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
return true
case 2:
case 3: // Schedule
const parts = cronExpression.trim().split(/\s+/)
if (parts.length !== 5) { toast.error(t("form.cronRequired")); return false }
return true
@@ -273,21 +257,25 @@ export function CreateScheduledScanDialog({
}
switch (currentStep) {
case 1:
case 1: // Basic info
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
return true
case 2: return true
case 3:
case 2: // Select target
if (selectionMode === "organization") {
if (!selectedOrgId) { toast.error(t("toast.selectOrganization")); return false }
} else {
if (!selectedTargetId) { toast.error(t("toast.selectTarget")); return false }
}
return true
case 4:
case 3: // Select engine
if (!selectedPresetId) { toast.error(t("form.scanEngineRequired")); return false }
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
return true
case 4: // Edit config
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
return true
case 5: // Schedule
const cronParts = cronExpression.trim().split(/\s+/)
if (cronParts.length !== 5) { toast.error(t("form.cronRequired")); return false }
return true
@@ -349,112 +337,30 @@ export function CreateScheduledScanDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t("createTitle")}</DialogTitle>
<DialogDescription>{t("createDesc")}</DialogDescription>
<DialogContent className="max-w-[900px] p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4">
<div className="flex items-center justify-between">
<div>
<DialogTitle>{t("createTitle")}</DialogTitle>
<DialogDescription className="mt-1">{t("createDesc")}</DialogDescription>
</div>
{/* Step indicator */}
<div className="text-sm text-muted-foreground mr-8">
{t("stepIndicator", { current: currentStep, total: totalSteps })}
</div>
</div>
</DialogHeader>
<div className="flex items-center justify-between px-2 py-4">
{steps.map((step, index) => (
<React.Fragment key={step.id}>
<div className="flex flex-col items-center gap-2">
<div className={cn(
"flex h-10 w-10 items-center justify-center rounded-full border-2 transition-colors",
currentStep > step.id ? "border-primary bg-primary text-primary-foreground"
: currentStep === step.id ? "border-primary text-primary"
: "border-muted text-muted-foreground"
)}>
{currentStep > step.id ? <IconCheck className="h-5 w-5" /> : <step.icon className="h-5 w-5" />}
</div>
<span className={cn("text-xs font-medium", currentStep >= step.id ? "text-foreground" : "text-muted-foreground")}>
{step.title}
</span>
</div>
{index < steps.length - 1 && (
<div className={cn("h-0.5 flex-1 mx-2", currentStep > step.id ? "bg-primary" : "bg-muted")} />
)}
</React.Fragment>
))}
</div>
<Separator />
<div className="flex-1 overflow-y-auto py-4 px-1">
{currentStep === 1 && (
<div className="space-y-6">
<div className="border-t h-[480px] overflow-hidden">
{/* Step 1: Basic Info + Scan Mode */}
{currentStep === 1 && !hasPreset && (
<div className="p-6 space-y-6 overflow-y-auto h-full">
<div className="space-y-2">
<Label htmlFor="name">{t("form.taskName")} *</Label>
<Input id="name" placeholder={t("form.taskNamePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} />
<p className="text-xs text-muted-foreground">{t("form.taskNameDesc")}</p>
</div>
<div className="space-y-2">
<Label>{t("form.scanEngine")} *</Label>
{engineIds.length > 0 && (
<p className="text-xs text-muted-foreground">{t("form.selectedEngines", { count: engineIds.length })}</p>
)}
<div className="border rounded-md p-3 max-h-[150px] overflow-y-auto space-y-2">
{engines.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("form.noEngine")}</p>
) : (
engines.map((engine) => (
<label
key={engine.id}
htmlFor={`engine-${engine.id}`}
className={cn(
"flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-all",
engineIds.includes(engine.id)
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted/50 border border-transparent"
)}
>
<Checkbox
id={`engine-${engine.id}`}
checked={engineIds.includes(engine.id)}
onCheckedChange={(checked) => handleEngineToggle(engine.id, checked as boolean)}
/>
<span className="text-sm">{engine.name}</span>
</label>
))
)}
</div>
<p className="text-xs text-muted-foreground">{t("form.scanEngineDesc")}</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("form.configuration")} *</Label>
{isConfigEdited && (
<Badge variant="outline" className="text-xs">
{t("form.configEdited")}
</Badge>
)}
</div>
{selectedCapabilities.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedCapabilities.map((capKey) => {
const config = CAPABILITY_CONFIG[capKey]
return (
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
{config?.label || capKey}
</Badge>
)
})}
</div>
)}
<div className="border rounded-md overflow-hidden h-[180px]">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
onValidationChange={handleYamlValidationChange}
/>
</div>
<p className="text-xs text-muted-foreground">{t("form.configurationDesc")}</p>
</div>
</div>
)}
{currentStep === 2 && !hasPreset && (
<div className="space-y-6">
<Separator />
<div className="space-y-3">
<Label>{t("form.selectScanMode")}</Label>
<div className="grid grid-cols-2 gap-4">
@@ -481,15 +387,16 @@ export function CreateScheduledScanDialog({
{selectionMode === "target" && <IconCheck className="h-5 w-5 text-primary" />}
</div>
</div>
<p className="text-sm text-muted-foreground">
{selectionMode === "organization" ? t("form.organizationScanHint") : t("form.targetScanHint")}
</p>
</div>
<p className="text-sm text-muted-foreground">
{selectionMode === "organization" ? t("form.organizationScanHint") : t("form.targetScanHint")}
</p>
</div>
)}
{currentStep === 3 && !hasPreset && (
<div className="space-y-4">
{/* Step 2: Select Target (Organization or Target) */}
{currentStep === 2 && !hasPreset && (
<div className="p-6 space-y-4 overflow-y-auto h-full">
{selectionMode === "organization" ? (
<>
<Label>{t("form.selectOrganization")}</Label>
@@ -568,8 +475,34 @@ export function CreateScheduledScanDialog({
</div>
)}
{/* Step 3 (full) / Step 1 (preset): Select Engine */}
{((currentStep === 3 && !hasPreset) || (currentStep === 1 && hasPreset)) && engines.length > 0 && (
<EnginePresetSelector
engines={engines}
selectedEngineIds={engineIds}
selectedPresetId={selectedPresetId}
onPresetChange={setSelectedPresetId}
onEngineIdsChange={handleEngineIdsChange}
onConfigurationChange={handlePresetConfigChange}
disabled={isPending}
/>
)}
{/* Step 4 (full) / Step 2 (preset): Edit Configuration */}
{((currentStep === 4 && !hasPreset) || (currentStep === 2 && hasPreset)) && (
<div className="space-y-6">
<ScanConfigEditor
configuration={configuration}
onChange={handleManualConfigChange}
onValidationChange={handleYamlValidationChange}
selectedEngines={selectedEngines}
isConfigEdited={isConfigEdited}
disabled={isPending}
/>
)}
{/* Step 5 (full) / Step 3 (preset): Schedule Settings */}
{((currentStep === 5 && !hasPreset) || (currentStep === 3 && hasPreset)) && (
<div className="p-6 space-y-6 overflow-y-auto h-full">
<div className="space-y-2">
<Label>{t("form.cronExpression")} *</Label>
<Input placeholder={t("form.cronPlaceholder")} value={cronExpression} onChange={(e) => setCronExpression(e.target.value)} className="font-mono" />
@@ -606,9 +539,7 @@ export function CreateScheduledScanDialog({
)}
</div>
<Separator />
<div className="flex justify-between pt-4">
<div className="px-6 py-4 border-t flex justify-between">
<Button variant="outline" onClick={goToPrevStep} disabled={currentStep === 1}>
<IconChevronLeft className="h-4 w-4 mr-1" />{t("buttons.previous")}
</Button>

View File

@@ -36,6 +36,7 @@ const converter = new AnsiToHtml({
export function AnsiLogViewer({ content, className }: AnsiLogViewerProps) {
const containerRef = useRef<HTMLPreElement>(null)
const isAtBottomRef = useRef(true) // 跟踪用户是否在底部
// 将 ANSI 转换为 HTML
const htmlContent = useMemo(() => {
@@ -43,9 +44,24 @@ export function AnsiLogViewer({ content, className }: AnsiLogViewerProps) {
return converter.toHtml(content)
}, [content])
// 自动滚动到底部
// 监听滚动事件,检测用户是否在底部
useEffect(() => {
if (containerRef.current) {
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
// 允许 30px 的容差,认为在底部附近
isAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 30
}
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [])
// 只有用户在底部时才自动滚动
useEffect(() => {
if (containerRef.current && isAtBottomRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [htmlContent])

View File

@@ -1,9 +1,9 @@
"use client"
import React, { useState, useCallback } from "react"
import React, { useState, useCallback, useEffect } from "react"
import Editor from "@monaco-editor/react"
import * as yaml from "js-yaml"
import { AlertCircle, CheckCircle2 } from "lucide-react"
import { AlertCircle } from "lucide-react"
import { useColorTheme } from "@/hooks/use-color-theme"
import { useTranslations } from "next-intl"
import { cn } from "@/lib/utils"
@@ -15,7 +15,6 @@ interface YamlEditorProps {
disabled?: boolean
height?: string
className?: string
showValidation?: boolean
onValidationChange?: (isValid: boolean, error?: { message: string; line?: number; column?: number }) => void
}
@@ -30,14 +29,45 @@ export function YamlEditor({
disabled = false,
height = "100%",
className,
showValidation = true,
onValidationChange,
}: YamlEditorProps) {
const t = useTranslations("common.yamlEditor")
const { currentTheme } = useColorTheme()
const [isEditorReady, setIsEditorReady] = useState(false)
const [shouldMount, setShouldMount] = useState(false)
const [yamlError, setYamlError] = useState<{ message: string; line?: number; column?: number } | null>(null)
// Delay mounting to avoid Monaco hitTest error on rapid container changes
useEffect(() => {
const timer = setTimeout(() => setShouldMount(true), 50)
return () => clearTimeout(timer)
}, [])
// Check for duplicate keys in YAML content
const checkDuplicateKeys = useCallback((content: string): { key: string; line: number } | null => {
const lines = content.split('\n')
const keyStack: { indent: number; keys: Set<string> }[] = [{ indent: -1, keys: new Set() }]
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Skip empty lines and comments
if (!line.trim() || line.trim().startsWith('#')) continue
// Match top-level keys (no leading whitespace, ends with colon)
const topLevelMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(?:#.*)?$/)
if (topLevelMatch) {
const key = topLevelMatch[1]
const currentLevel = keyStack[0]
if (currentLevel.keys.has(key)) {
return { key, line: i + 1 }
}
currentLevel.keys.add(key)
}
}
return null
}, [])
// Validate YAML syntax
const validateYaml = useCallback((content: string) => {
if (!content.trim()) {
@@ -46,6 +76,19 @@ export function YamlEditor({
return true
}
// First check for duplicate keys
const duplicateKey = checkDuplicateKeys(content)
if (duplicateKey) {
const errorInfo = {
message: t("duplicateKey", { key: duplicateKey.key }),
line: duplicateKey.line,
column: 1,
}
setYamlError(errorInfo)
onValidationChange?.(false, errorInfo)
return false
}
try {
yaml.load(content)
setYamlError(null)
@@ -62,7 +105,7 @@ export function YamlEditor({
onValidationChange?.(false, errorInfo)
return false
}
}, [onValidationChange])
}, [onValidationChange, checkDuplicateKeys, t])
// Handle editor content change
const handleEditorChange = useCallback((newValue: string | undefined) => {
@@ -73,74 +116,63 @@ export function YamlEditor({
// Handle editor mount
const handleEditorDidMount = useCallback(() => {
setIsEditorReady(true)
// Validate initial content
validateYaml(value)
}, [validateYaml, value])
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Validation status */}
{showValidation && (
<div className="flex items-center justify-end px-2 py-1 border-b bg-muted/30">
{value.trim() && (
yamlError ? (
<div className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5" />
<span>{t("syntaxError")}</span>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<CheckCircle2 className="h-3.5 w-3.5" />
<span>{t("syntaxValid")}</span>
</div>
)
)}
</div>
)}
{/* Monaco Editor */}
<div className={cn("flex-1 overflow-hidden", yamlError ? 'border-destructive' : '')}>
<Editor
height={height}
defaultLanguage="yaml"
value={value}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
theme={currentTheme.isDark ? "vs-dark" : "light"}
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
wordWrap: "off",
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
formatOnPaste: true,
formatOnType: true,
folding: true,
foldingStrategy: "indentation",
showFoldingControls: "mouseover",
bracketPairColorization: {
enabled: true,
},
padding: {
top: 8,
bottom: 8,
},
readOnly: disabled,
placeholder: placeholder,
}}
loading={
<div className="flex items-center justify-center h-full bg-muted/30">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-xs text-muted-foreground">{t("loading")}</p>
{shouldMount ? (
<Editor
height={height}
defaultLanguage="yaml"
value={value}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
theme={currentTheme.isDark ? "vs-dark" : "light"}
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "off",
wordWrap: "off",
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
formatOnPaste: true,
formatOnType: true,
folding: true,
foldingStrategy: "indentation",
showFoldingControls: "mouseover",
bracketPairColorization: {
enabled: true,
},
padding: {
top: 8,
bottom: 8,
},
readOnly: disabled,
placeholder: placeholder,
}}
loading={
<div className="flex items-center justify-center h-full bg-muted/30">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-xs text-muted-foreground">{t("loading")}</p>
</div>
</div>
}
/>
) : (
<div className="flex items-center justify-center h-full bg-muted/30">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-xs text-muted-foreground">{t("loading")}</p>
</div>
}
/>
</div>
)}
</div>
{/* Error message display */}

View File

@@ -0,0 +1,106 @@
/**
* 扫描日志轮询 Hook
*
* 功能:
* - 初始加载获取全部日志
* - 增量轮询获取新日志3s 间隔)
* - 扫描结束后停止轮询
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { getScanLogs, type ScanLog } from '@/services/scan.service'
interface UseScanLogsOptions {
scanId: number
enabled?: boolean
pollingInterval?: number // 默认 3000ms
}
interface UseScanLogsReturn {
logs: ScanLog[]
loading: boolean
refetch: () => void
}
export function useScanLogs({
scanId,
enabled = true,
pollingInterval = 3000,
}: UseScanLogsOptions): UseScanLogsReturn {
const [logs, setLogs] = useState<ScanLog[]>([])
const [loading, setLoading] = useState(false)
const lastLogId = useRef<number | null>(null)
const isMounted = useRef(true)
const fetchLogs = useCallback(async (incremental = false) => {
if (!enabled || !isMounted.current) return
setLoading(true)
try {
const params: { limit: number; afterId?: number } = { limit: 200 }
if (incremental && lastLogId.current !== null) {
params.afterId = lastLogId.current
}
const response = await getScanLogs(scanId, params)
const newLogs = response.results
if (!isMounted.current) return
if (newLogs.length > 0) {
// 使用 ID 作为游标ID 是唯一且自增的,避免时间戳重复导致的重复日志
lastLogId.current = newLogs[newLogs.length - 1].id
if (incremental) {
// 按 ID 去重,防止 React Strict Mode 或竞态条件导致的重复
setLogs(prev => {
const existingIds = new Set(prev.map(l => l.id))
const uniqueNewLogs = newLogs.filter(l => !existingIds.has(l.id))
return uniqueNewLogs.length > 0 ? [...prev, ...uniqueNewLogs] : prev
})
} else {
setLogs(newLogs)
}
}
} catch (error) {
console.error('Failed to fetch scan logs:', error)
} finally {
if (isMounted.current) {
setLoading(false)
}
}
}, [scanId, enabled])
// 初始加载
useEffect(() => {
isMounted.current = true
if (enabled) {
// 重置状态
setLogs([])
lastLogId.current = null
fetchLogs(false)
}
return () => {
isMounted.current = false
}
}, [scanId, enabled])
// 轮询
useEffect(() => {
if (!enabled) return
const interval = setInterval(() => {
fetchLogs(true) // 增量查询
}, pollingInterval)
return () => clearInterval(interval)
}, [enabled, pollingInterval, fetchLogs])
const refetch = useCallback(() => {
setLogs([])
lastLogId.current = null
fetchLogs(false)
}, [fetchLogs])
return { logs, loading, refetch }
}

View File

@@ -179,7 +179,8 @@
"syntaxError": "Syntax Error",
"syntaxValid": "Syntax Valid",
"errorLocation": "Line {line}, Column {column}",
"loading": "Loading editor..."
"loading": "Loading editor...",
"duplicateKey": "Duplicate key '{key}' found. Later values will override earlier ones. Please remove duplicates."
},
"theme": {
"switchToLight": "Switch to light mode",
@@ -660,7 +661,40 @@
"noConfig": "No config",
"initiating": "Initiating...",
"startScan": "Start Scan",
"selectedCount": "{count} engines selected"
"selectedCount": "{count} engines selected",
"configTitle": "Scan Configuration",
"configEdited": "Edited",
"stepIndicator": "Step {current}/{total}",
"back": "Back",
"next": "Next",
"steps": {
"selectEngine": "Select Engine",
"editConfig": "Edit Config"
},
"presets": {
"title": "Quick Select",
"fullScan": "Full Scan",
"fullScanDesc": "Complete security assessment covering asset discovery to vulnerability detection",
"recon": "Reconnaissance",
"reconDesc": "Discover and identify target assets including subdomains, ports, sites and fingerprints",
"vulnScan": "Vulnerability Scan",
"vulnScanDesc": "Detect security vulnerabilities on known assets",
"custom": "Custom",
"customDesc": "Manually select engine combination",
"customHint": "Click to manually select engines",
"selectHint": "Please select a scan preset",
"selectEngines": "Select Engines",
"enginesCount": "engines",
"capabilities": "Capabilities",
"usedEngines": "Used Engines",
"noCapabilities": "Please select engines"
},
"overwriteConfirm": {
"title": "Overwrite Configuration",
"description": "You have manually edited the configuration. Changing engines will overwrite your changes. Continue?",
"cancel": "Cancel",
"confirm": "Overwrite"
}
},
"cron": {
"everyMinute": "Every minute",
@@ -703,6 +737,8 @@
"status": "Status",
"errorReason": "Error Reason",
"totalProgress": "Total Progress",
"tab_stages": "Stages",
"tab_logs": "Logs",
"status_running": "Scanning",
"status_cancelled": "Cancelled",
"status_completed": "Completed",
@@ -742,10 +778,13 @@
"createDesc": "Configure scheduled scan task and set execution plan",
"editTitle": "Edit Scheduled Scan",
"editDesc": "Modify scheduled scan task configuration",
"stepIndicator": "Step {current}/{total}",
"steps": {
"basicInfo": "Basic Info",
"scanMode": "Scan Mode",
"selectTarget": "Select Target",
"selectEngine": "Select Engine",
"editConfig": "Edit Config",
"scheduleSettings": "Schedule Settings"
},
"form": {
@@ -794,6 +833,8 @@
"organizationModeHint": "In organization scan mode, all targets under this organization will be dynamically fetched at execution",
"noAvailableTarget": "No available targets",
"noEngine": "No engines available",
"noConfig": "No config",
"capabilitiesCount": "{count} capabilities",
"selected": "Selected",
"selectedEngines": "{count} engines selected"
},
@@ -1730,7 +1771,8 @@
},
"step1Title": "Enter Targets",
"step2Title": "Select Engines",
"step3Title": "Confirm",
"step3Title": "Edit Config",
"stepIndicator": "Step {current}/{total}",
"step1Hint": "Enter scan targets in the left input box, one per line",
"step": "Step {current}/{total} · {title}",
"targetPlaceholder": "Enter one target per line, supported formats:\n\nDomain: example.com, sub.example.com\nIP Address: 192.168.1.1, 10.0.0.1\nCIDR: 192.168.1.0/24, 10.0.0.0/8\nURL: https://example.com/api/v1",
@@ -1758,6 +1800,14 @@
"andMore": "{count} more...",
"selectedEngines": "Selected Engines",
"confirmSummary": "Will scan {targetCount} targets with {engineCount} engines",
"configTitle": "Scan Configuration",
"configEdited": "Edited",
"overwriteConfirm": {
"title": "Overwrite Configuration",
"description": "You have manually edited the configuration. Changing engines will overwrite your changes. Continue?",
"cancel": "Cancel",
"confirm": "Overwrite"
},
"toast": {
"noValidTarget": "Please enter at least one valid target",
"hasInvalidInputs": "{count} invalid inputs, please fix before continuing",

View File

@@ -179,7 +179,8 @@
"syntaxError": "语法错误",
"syntaxValid": "语法正确",
"errorLocation": "第 {line} 行,第 {column} 列",
"loading": "加载编辑器..."
"loading": "加载编辑器...",
"duplicateKey": "发现重复的配置项 '{key}',后面的配置会覆盖前面的,请删除重复项"
},
"theme": {
"switchToLight": "切换到亮色模式",
@@ -660,7 +661,40 @@
"noConfig": "无配置",
"initiating": "发起中...",
"startScan": "开始扫描",
"selectedCount": "已选择 {count} 个引擎"
"selectedCount": "已选择 {count} 个引擎",
"configTitle": "扫描配置",
"configEdited": "已编辑",
"stepIndicator": "步骤 {current}/{total}",
"back": "上一步",
"next": "下一步",
"steps": {
"selectEngine": "选择引擎",
"editConfig": "编辑配置"
},
"presets": {
"title": "推荐组合",
"fullScan": "全量扫描",
"fullScanDesc": "完整的安全评估,覆盖资产发现到漏洞检测的全部流程",
"recon": "信息收集",
"reconDesc": "发现和识别目标资产,包括子域名、端口、站点和指纹",
"vulnScan": "漏洞扫描",
"vulnScanDesc": "对已知资产进行安全漏洞检测",
"custom": "自定义",
"customDesc": "手动选择引擎组合",
"customHint": "点击选择后手动勾选引擎",
"selectHint": "请选择一个扫描方案",
"selectEngines": "选择引擎",
"enginesCount": "个引擎",
"capabilities": "涉及能力",
"usedEngines": "使用引擎",
"noCapabilities": "请选择引擎"
},
"overwriteConfirm": {
"title": "覆盖配置确认",
"description": "您已手动编辑过配置,切换引擎将覆盖当前配置。是否继续?",
"cancel": "取消",
"confirm": "确认覆盖"
}
},
"cron": {
"everyMinute": "每分钟",
@@ -703,6 +737,8 @@
"status": "状态",
"errorReason": "错误原因",
"totalProgress": "总进度",
"tab_stages": "阶段",
"tab_logs": "日志",
"status_running": "扫描中",
"status_cancelled": "已取消",
"status_completed": "已完成",
@@ -742,10 +778,13 @@
"createDesc": "配置定时扫描任务,设置执行计划",
"editTitle": "编辑定时扫描",
"editDesc": "修改定时扫描任务配置",
"stepIndicator": "步骤 {current}/{total}",
"steps": {
"basicInfo": "基本信息",
"scanMode": "扫描模式",
"selectTarget": "选择目标",
"selectEngine": "选择引擎",
"editConfig": "编辑配置",
"scheduleSettings": "调度设置"
},
"form": {
@@ -794,6 +833,8 @@
"organizationModeHint": "组织扫描模式下,执行时将动态获取该组织下所有目标",
"noAvailableTarget": "暂无可用目标",
"noEngine": "暂无可用引擎",
"noConfig": "无配置",
"capabilitiesCount": "{count} 项能力",
"selected": "已选择",
"selectedEngines": "已选择 {count} 个引擎"
},
@@ -1730,7 +1771,8 @@
},
"step1Title": "输入目标",
"step2Title": "选择引擎",
"step3Title": "确认",
"step3Title": "编辑配置",
"stepIndicator": "步骤 {current}/{total}",
"step1Hint": "在左侧输入框中输入扫描目标,每行一个",
"step": "步骤 {current}/{total} · {title}",
"targetPlaceholder": "每行输入一个目标,支持以下格式:\n\n域名: example.com, sub.example.com\nIP地址: 192.168.1.1, 10.0.0.1\nCIDR网段: 192.168.1.0/24, 10.0.0.0/8\nURL: https://example.com/api/v1",
@@ -1758,6 +1800,14 @@
"andMore": "还有 {count} 个...",
"selectedEngines": "已选引擎",
"confirmSummary": "将使用 {engineCount} 个引擎扫描 {targetCount} 个目标",
"configTitle": "扫描配置",
"configEdited": "已编辑",
"overwriteConfirm": {
"title": "覆盖配置确认",
"description": "您已手动编辑过配置,切换引擎将覆盖当前配置。是否继续?",
"cancel": "取消",
"confirm": "确认覆盖"
},
"toast": {
"noValidTarget": "请输入至少一个有效目标",
"hasInvalidInputs": "存在 {count} 个无效输入,请修正后继续",

View File

@@ -0,0 +1,187 @@
import type { Directory, DirectoryListResponse } from '@/types/directory.types'
export const mockDirectories: Directory[] = [
{
id: 1,
url: 'https://acme.com/admin',
status: 200,
contentLength: 12345,
words: 1234,
lines: 89,
contentType: 'text/html',
duration: 0.234,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
url: 'https://acme.com/api',
status: 301,
contentLength: 0,
words: 0,
lines: 0,
contentType: 'text/html',
duration: 0.056,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
url: 'https://acme.com/login',
status: 200,
contentLength: 8765,
words: 567,
lines: 45,
contentType: 'text/html',
duration: 0.189,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
url: 'https://acme.com/dashboard',
status: 302,
contentLength: 0,
words: 0,
lines: 0,
contentType: 'text/html',
duration: 0.078,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
url: 'https://acme.com/static/js/app.js',
status: 200,
contentLength: 456789,
words: 12345,
lines: 5678,
contentType: 'application/javascript',
duration: 0.345,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:04:00Z',
},
{
id: 6,
url: 'https://acme.com/.git/config',
status: 200,
contentLength: 234,
words: 45,
lines: 12,
contentType: 'text/plain',
duration: 0.023,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:05:00Z',
},
{
id: 7,
url: 'https://acme.com/backup.zip',
status: 200,
contentLength: 12345678,
words: null,
lines: null,
contentType: 'application/zip',
duration: 1.234,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:06:00Z',
},
{
id: 8,
url: 'https://acme.com/robots.txt',
status: 200,
contentLength: 567,
words: 89,
lines: 23,
contentType: 'text/plain',
duration: 0.034,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:07:00Z',
},
{
id: 9,
url: 'https://api.acme.com/v1/health',
status: 200,
contentLength: 45,
words: 5,
lines: 1,
contentType: 'application/json',
duration: 0.012,
websiteUrl: 'https://api.acme.com',
createdAt: '2024-12-28T10:08:00Z',
},
{
id: 10,
url: 'https://api.acme.com/swagger-ui.html',
status: 200,
contentLength: 23456,
words: 1234,
lines: 234,
contentType: 'text/html',
duration: 0.267,
websiteUrl: 'https://api.acme.com',
createdAt: '2024-12-28T10:09:00Z',
},
{
id: 11,
url: 'https://techstart.io/wp-admin',
status: 302,
contentLength: 0,
words: 0,
lines: 0,
contentType: 'text/html',
duration: 0.089,
websiteUrl: 'https://techstart.io',
createdAt: '2024-12-26T08:45:00Z',
},
{
id: 12,
url: 'https://techstart.io/wp-login.php',
status: 200,
contentLength: 4567,
words: 234,
lines: 78,
contentType: 'text/html',
duration: 0.156,
websiteUrl: 'https://techstart.io',
createdAt: '2024-12-26T08:46:00Z',
},
]
export function getMockDirectories(params?: {
page?: number
pageSize?: number
filter?: string
targetId?: number
scanId?: number
}): DirectoryListResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockDirectories
if (filter) {
filtered = filtered.filter(
d =>
d.url.toLowerCase().includes(filter) ||
d.contentType.toLowerCase().includes(filter)
)
}
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 getMockDirectoryById(id: number): Directory | undefined {
return mockDirectories.find(d => d.id === id)
}

View File

@@ -0,0 +1,593 @@
import type {
EholeFingerprint,
GobyFingerprint,
WappalyzerFingerprint,
FingersFingerprint,
FingerPrintHubFingerprint,
ARLFingerprint,
FingerprintStats,
} from '@/types/fingerprint.types'
import type { PaginatedResponse } from '@/types/api-response.types'
// ==================== EHole 指纹数据(真实数据示例)====================
export const mockEholeFingerprints: EholeFingerprint[] = [
{
id: 1,
cms: '致远OA',
method: 'keyword',
location: 'body',
keyword: ['/seeyon/USER-DATA/IMAGES/LOGIN/login.gif'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
cms: '通达OA',
method: 'keyword',
location: 'body',
keyword: ['/static/images/tongda.ico'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
cms: 'Nexus Repository Manager',
method: 'keyword',
location: 'title',
keyword: ['Nexus Repository Manager'],
isImportant: true,
type: 'cloud',
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
cms: '禅道 zentao',
method: 'keyword',
location: 'title',
keyword: ['Welcome to use zentao'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
cms: 'Kibana',
method: 'keyword',
location: 'title',
keyword: ['Kibana'],
isImportant: true,
type: 'cloud',
createdAt: '2024-12-20T10:04:00Z',
},
{
id: 6,
cms: 'Spring env',
method: 'keyword',
location: 'body',
keyword: ['Whitelabel Error Page'],
isImportant: true,
type: 'framework',
createdAt: '2024-12-20T10:05:00Z',
},
{
id: 7,
cms: '泛微OA',
method: 'keyword',
location: 'header',
keyword: ['ecology_JSessionid'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:06:00Z',
},
{
id: 8,
cms: '用友NC',
method: 'keyword',
location: 'body',
keyword: ['UFIDA', '/nc/servlet/nc.ui.iufo.login.Index'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:07:00Z',
},
]
// ==================== Goby 指纹数据(真实数据示例)====================
export const mockGobyFingerprints: GobyFingerprint[] = [
{
id: 1,
name: 'WebSphere-App-Server',
logic: '((a||b) &&c&&d) || (e&&f&&g)',
rule: [
{ label: 'a', feature: 'Server: WebSphere Application Server', is_equal: true },
{ label: 'b', feature: 'IBM WebSphere Application Server', is_equal: true },
{ label: 'c', feature: 'couchdb', is_equal: false },
{ label: 'd', feature: 'drupal', is_equal: false },
{ label: 'e', feature: 'Server: WebSphere Application Server', is_equal: true },
{ label: 'f', feature: 'couchdb', is_equal: false },
{ label: 'g', feature: 'drupal', is_equal: false },
],
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
name: 'Wing-FTP-Server',
logic: 'a||b||c||d',
rule: [
{ label: 'a', feature: 'Server: Wing FTP Server', is_equal: true },
{ label: 'b', feature: 'Server: Wing FTP Server', is_equal: true },
{ label: 'c', feature: '/help_javascript.htm', is_equal: true },
{ label: 'd', feature: 'Wing FTP Server', is_equal: true },
],
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
name: 'Fortinet-sslvpn',
logic: 'a&&b',
rule: [
{ label: 'a', feature: 'fgt_lang', is_equal: true },
{ label: 'b', feature: '/sslvpn/portal.html', is_equal: true },
],
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
name: 'D-link-DSL-2640B',
logic: 'a||b',
rule: [
{ label: 'a', feature: 'Product : DSL-2640B', is_equal: true },
{ label: 'b', feature: 'D-Link DSL-2640B', is_equal: true },
],
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
name: 'Kedacom-NVR',
logic: 'a|| (b&&c) ||d',
rule: [
{ label: 'a', feature: 'NVR Station Web', is_equal: true },
{ label: 'b', feature: 'location="index_cn.htm";', is_equal: true },
{ label: 'c', feature: 'if(syslan == "zh-cn"', is_equal: true },
{ label: 'd', feature: 'WMS browse NVR', is_equal: true },
],
createdAt: '2024-12-20T10:04:00Z',
},
]
// ==================== Wappalyzer 指纹数据(真实数据示例)====================
export const mockWappalyzerFingerprints: WappalyzerFingerprint[] = [
{
id: 1,
name: '1C-Bitrix',
cats: [1, 6],
cookies: { bitrix_sm_guest_id: '', bitrix_sm_last_ip: '', bitrix_sm_sale_uid: '' },
headers: { 'set-cookie': 'bitrix_', 'x-powered-cms': 'bitrix site manager' },
scriptSrc: ['bitrix(?:\\.info/|/js/main/core)'],
js: [],
implies: ['PHP'],
meta: {},
html: [],
description: '1C-Bitrix is a system of web project management.',
website: 'https://www.1c-bitrix.ru',
cpe: '',
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
name: 'React',
cats: [12],
cookies: {},
headers: {},
scriptSrc: ['react(?:-dom)?(?:\\.min)?\\.js'],
js: ['React.version'],
implies: [],
meta: {},
html: ['data-reactroot'],
description: 'React is a JavaScript library for building user interfaces.',
website: 'https://reactjs.org',
cpe: 'cpe:/a:facebook:react',
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
name: 'Vue.js',
cats: [12],
cookies: {},
headers: {},
scriptSrc: ['vue(?:\\.min)?\\.js'],
js: ['Vue.version'],
implies: [],
meta: {},
html: ['data-v-'],
description: 'Vue.js is a progressive JavaScript framework.',
website: 'https://vuejs.org',
cpe: 'cpe:/a:vuejs:vue',
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
name: 'nginx',
cats: [22],
cookies: {},
headers: { server: 'nginx(?:/([\\d.]+))?\\;version:\\1' },
scriptSrc: [],
js: [],
implies: [],
meta: {},
html: [],
description: 'nginx is a web server.',
website: 'http://nginx.org/en',
cpe: 'cpe:/a:nginx:nginx',
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
name: 'WordPress',
cats: [1, 11],
cookies: {},
headers: { 'x-pingback': '/xmlrpc\\.php$' },
scriptSrc: ['/wp-(?:content|includes)/'],
js: [],
implies: ['PHP', 'MySQL'],
meta: { generator: ['WordPress(?: ([\\d.]+))?\\;version:\\1'] },
html: ['<link rel=["\']stylesheet["\'] [^>]+/wp-(?:content|includes)/'],
description: 'WordPress is a free and open-source CMS.',
website: 'https://wordpress.org',
cpe: 'cpe:/a:wordpress:wordpress',
createdAt: '2024-12-20T10:04:00Z',
},
]
// ==================== Fingers 指纹数据(真实数据示例)====================
export const mockFingersFingerprints: FingersFingerprint[] = [
{
id: 1,
name: 'jenkins',
link: '',
rule: [
{
favicon_hash: ['81586312'],
body: 'Jenkins',
header: 'X-Jenkins',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [8080],
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
name: 'gitlab',
link: '',
rule: [
{
favicon_hash: ['516963061', '1278323681'],
body: 'GitLab',
header: '_gitlab_session',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [80, 443],
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
name: 'nacos',
link: '',
rule: [
{
body: '<title>Nacos</title>',
send_data: '/nacos/',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [8848],
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
name: 'elasticsearch',
link: '',
rule: [
{
body: '"cluster_name" : "elasticsearch"',
vuln: 'elasticsearch_unauth',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [9200],
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
name: 'zabbix',
link: '',
rule: [
{
favicon_hash: ['892542951'],
body: 'images/general/zabbix.ico',
header: 'zbx_sessionid',
send_data: '/zabbix',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [80, 443],
createdAt: '2024-12-20T10:04:00Z',
},
]
// ==================== FingerPrintHub 指纹数据(真实数据示例)====================
export const mockFingerPrintHubFingerprints: FingerPrintHubFingerprint[] = [
{
id: 1,
fpId: 'apache-tomcat',
name: 'Apache Tomcat',
author: 'pdteam',
tags: 'tech,apache,tomcat',
severity: 'info',
metadata: {
product: 'tomcat',
vendor: 'apache',
verified: true,
shodan_query: 'http.favicon.hash:"-297069493"',
fofa_query: 'app="Apache-Tomcat"',
},
http: [
{
method: 'GET',
path: '/',
matchers: [
{ type: 'word', part: 'body', words: ['Apache Tomcat'] },
{ type: 'status', status: [200] },
],
},
],
sourceFile: 'http/technologies/apache/apache-tomcat.yaml',
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
fpId: 'nginx-detect',
name: 'Nginx Server',
author: 'pdteam',
tags: 'tech,nginx',
severity: 'info',
metadata: {
product: 'nginx',
vendor: 'nginx',
verified: true,
},
http: [
{
method: 'GET',
path: '/',
matchers: [
{ type: 'regex', part: 'header', regex: ['[Nn]ginx'] },
],
extractors: [
{ type: 'regex', part: 'header', regex: ['nginx/([\\d.]+)'], group: 1 },
],
},
],
sourceFile: 'http/technologies/nginx/nginx-version.yaml',
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
fpId: 'spring-boot-detect',
name: 'Spring Boot',
author: 'pdteam',
tags: 'tech,spring,java',
severity: 'info',
metadata: {
product: 'spring-boot',
vendor: 'vmware',
verified: true,
},
http: [
{
method: 'GET',
path: '/',
matchers: [
{ type: 'word', part: 'body', words: ['Whitelabel Error Page'] },
],
},
],
sourceFile: 'http/technologies/spring/spring-boot.yaml',
createdAt: '2024-12-20T10:02:00Z',
},
]
// ==================== ARL 指纹数据(真实数据示例)====================
export const mockARLFingerprints: ARLFingerprint[] = [
{
id: 1,
name: 'Shiro',
rule: 'header="rememberMe="',
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
name: 'ThinkPHP',
rule: 'body="ThinkPHP" || header="ThinkPHP"',
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
name: 'Fastjson',
rule: 'body="fastjson" || body="com.alibaba.fastjson"',
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
name: 'Weblogic',
rule: 'body="WebLogic" || header="WebLogic" || body="bea_wls_internal"',
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
name: 'JBoss',
rule: 'body="JBoss" || header="JBoss" || body="jboss.css"',
createdAt: '2024-12-20T10:04:00Z',
},
{
id: 6,
name: 'Struts2',
rule: 'body=".action" || body="struts"',
createdAt: '2024-12-20T10:05:00Z',
},
]
// ==================== 统计数据 ====================
export const mockFingerprintStats: FingerprintStats = {
ehole: 1892,
goby: 4567,
wappalyzer: 3456,
fingers: 2345,
fingerprinthub: 8901,
arl: 1234,
}
// ==================== 查询函数 ====================
export function getMockEholeFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<EholeFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockEholeFingerprints
if (filter) {
filtered = filtered.filter(f => f.cms.toLowerCase().includes(filter))
}
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 getMockGobyFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<GobyFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockGobyFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
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 getMockWappalyzerFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<WappalyzerFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockWappalyzerFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
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 getMockFingersFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<FingersFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockFingersFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
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 getMockFingerPrintHubFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<FingerPrintHubFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockFingerPrintHubFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
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 getMockARLFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<ARLFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockARLFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
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 getMockFingerprintStats(): FingerprintStats {
return mockFingerprintStats
}

View File

@@ -0,0 +1,118 @@
import type { IPAddress, GetIPAddressesResponse } from '@/types/ip-address.types'
// 使用函数生成IP地址
const ip = (a: number, b: number, c: number, d: number) => `${a}.${b}.${c}.${d}`
export const mockIPAddresses: IPAddress[] = [
{
ip: ip(192, 0, 2, 1),
hosts: ['router.local', 'gateway.lan'],
ports: [80, 443, 22, 53],
createdAt: '2024-12-28T10:00:00Z',
},
{
ip: ip(192, 0, 2, 10),
hosts: ['api.acme.com', 'backend.acme.com'],
ports: [80, 443, 8080, 3306],
createdAt: '2024-12-28T10:01:00Z',
},
{
ip: ip(192, 0, 2, 11),
hosts: ['web.acme.com', 'www.acme.com'],
ports: [80, 443],
createdAt: '2024-12-28T10:02:00Z',
},
{
ip: ip(198, 51, 100, 50),
hosts: ['db.internal.acme.com'],
ports: [3306, 5432, 27017],
createdAt: '2024-12-28T10:03:00Z',
},
{
ip: ip(203, 0, 113, 50),
hosts: ['cdn.acme.com'],
ports: [80, 443],
createdAt: '2024-12-28T10:04:00Z',
},
{
ip: ip(198, 51, 100, 10),
hosts: ['mail.acme.com', 'smtp.acme.com'],
ports: [25, 465, 587, 993, 995],
createdAt: '2024-12-28T10:05:00Z',
},
{
ip: ip(192, 0, 2, 100),
hosts: ['jenkins.acme.com'],
ports: [8080, 50000],
createdAt: '2024-12-28T10:06:00Z',
},
{
ip: ip(192, 0, 2, 101),
hosts: ['gitlab.acme.com'],
ports: [80, 443, 22],
createdAt: '2024-12-28T10:07:00Z',
},
{
ip: ip(192, 0, 2, 102),
hosts: ['k8s.acme.com', 'kubernetes.acme.com'],
ports: [6443, 10250, 10251, 10252],
createdAt: '2024-12-28T10:08:00Z',
},
{
ip: ip(192, 0, 2, 103),
hosts: ['elastic.acme.com'],
ports: [9200, 9300, 5601],
createdAt: '2024-12-28T10:09:00Z',
},
{
ip: ip(192, 0, 2, 104),
hosts: ['redis.acme.com'],
ports: [6379],
createdAt: '2024-12-28T10:10:00Z',
},
{
ip: ip(192, 0, 2, 105),
hosts: ['mq.acme.com', 'rabbitmq.acme.com'],
ports: [5672, 15672],
createdAt: '2024-12-28T10:11:00Z',
},
]
export function getMockIPAddresses(params?: {
page?: number
pageSize?: number
filter?: string
targetId?: number
scanId?: number
}): GetIPAddressesResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockIPAddresses
if (filter) {
filtered = filtered.filter(
ipAddr =>
ipAddr.ip.toLowerCase().includes(filter) ||
ipAddr.hosts.some(h => h.toLowerCase().includes(filter))
)
}
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 getMockIPAddressByIP(ipStr: string): IPAddress | undefined {
return mockIPAddresses.find(addr => addr.ip === ipStr)
}

View File

@@ -0,0 +1,35 @@
import type {
NotificationSettings,
GetNotificationSettingsResponse,
UpdateNotificationSettingsResponse,
} from '@/types/notification-settings.types'
export const mockNotificationSettings: NotificationSettings = {
discord: {
enabled: true,
webhookUrl: 'https://discord.com/api/webhooks/1234567890/abcdefghijklmnop',
},
categories: {
scan: true,
vulnerability: true,
asset: true,
system: false,
},
}
export function getMockNotificationSettings(): GetNotificationSettingsResponse {
return mockNotificationSettings
}
export function updateMockNotificationSettings(
settings: NotificationSettings
): UpdateNotificationSettingsResponse {
// 模拟更新设置
Object.assign(mockNotificationSettings, settings)
return {
message: 'Notification settings updated successfully',
discord: mockNotificationSettings.discord,
categories: mockNotificationSettings.categories,
}
}

View File

@@ -0,0 +1,240 @@
import type {
NucleiTemplateTreeNode,
NucleiTemplateTreeResponse,
NucleiTemplateContent,
} from '@/types/nuclei.types'
export const mockNucleiTemplateTree: NucleiTemplateTreeNode[] = [
{
type: 'folder',
name: 'cves',
path: 'cves',
children: [
{
type: 'folder',
name: '2024',
path: 'cves/2024',
children: [
{
type: 'file',
name: 'CVE-2024-1234.yaml',
path: 'cves/2024/CVE-2024-1234.yaml',
templateId: 'CVE-2024-1234',
severity: 'critical',
tags: ['cve', 'rce'],
},
{
type: 'file',
name: 'CVE-2024-5678.yaml',
path: 'cves/2024/CVE-2024-5678.yaml',
templateId: 'CVE-2024-5678',
severity: 'high',
tags: ['cve', 'sqli'],
},
],
},
{
type: 'folder',
name: '2023',
path: 'cves/2023',
children: [
{
type: 'file',
name: 'CVE-2023-9876.yaml',
path: 'cves/2023/CVE-2023-9876.yaml',
templateId: 'CVE-2023-9876',
severity: 'high',
tags: ['cve', 'auth-bypass'],
},
],
},
],
},
{
type: 'folder',
name: 'vulnerabilities',
path: 'vulnerabilities',
children: [
{
type: 'folder',
name: 'generic',
path: 'vulnerabilities/generic',
children: [
{
type: 'file',
name: 'sqli-error-based.yaml',
path: 'vulnerabilities/generic/sqli-error-based.yaml',
templateId: 'sqli-error-based',
severity: 'high',
tags: ['sqli', 'generic'],
},
{
type: 'file',
name: 'xss-reflected.yaml',
path: 'vulnerabilities/generic/xss-reflected.yaml',
templateId: 'xss-reflected',
severity: 'medium',
tags: ['xss', 'generic'],
},
],
},
],
},
{
type: 'folder',
name: 'technologies',
path: 'technologies',
children: [
{
type: 'file',
name: 'nginx-version.yaml',
path: 'technologies/nginx-version.yaml',
templateId: 'nginx-version',
severity: 'info',
tags: ['tech', 'nginx'],
},
{
type: 'file',
name: 'apache-detect.yaml',
path: 'technologies/apache-detect.yaml',
templateId: 'apache-detect',
severity: 'info',
tags: ['tech', 'apache'],
},
],
},
{
type: 'folder',
name: 'exposures',
path: 'exposures',
children: [
{
type: 'folder',
name: 'configs',
path: 'exposures/configs',
children: [
{
type: 'file',
name: 'git-config.yaml',
path: 'exposures/configs/git-config.yaml',
templateId: 'git-config',
severity: 'medium',
tags: ['exposure', 'git'],
},
{
type: 'file',
name: 'env-file.yaml',
path: 'exposures/configs/env-file.yaml',
templateId: 'env-file',
severity: 'high',
tags: ['exposure', 'env'],
},
],
},
],
},
]
export const mockNucleiTemplateContent: Record<string, NucleiTemplateContent> = {
'cves/2024/CVE-2024-1234.yaml': {
path: 'cves/2024/CVE-2024-1234.yaml',
name: 'CVE-2024-1234.yaml',
templateId: 'CVE-2024-1234',
severity: 'critical',
tags: ['cve', 'rce'],
content: `id: CVE-2024-1234
info:
name: Example RCE Vulnerability
author: pdteam
severity: critical
description: |
Example remote code execution vulnerability.
reference:
- https://example.com/cve-2024-1234
tags: cve,cve2024,rce
http:
- method: POST
path:
- "{{BaseURL}}/api/execute"
headers:
Content-Type: application/json
body: '{"cmd": "id"}'
matchers:
- type: word
words:
- "uid="
- "gid="
condition: and
`,
},
'vulnerabilities/generic/sqli-error-based.yaml': {
path: 'vulnerabilities/generic/sqli-error-based.yaml',
name: 'sqli-error-based.yaml',
templateId: 'sqli-error-based',
severity: 'high',
tags: ['sqli', 'generic'],
content: `id: sqli-error-based
info:
name: Error Based SQL Injection
author: pdteam
severity: high
tags: sqli,generic
http:
- method: GET
path:
- "{{BaseURL}}/?id=1'"
matchers:
- type: word
words:
- "SQL syntax"
- "mysql_fetch"
- "You have an error"
condition: or
`,
},
'technologies/nginx-version.yaml': {
path: 'technologies/nginx-version.yaml',
name: 'nginx-version.yaml',
templateId: 'nginx-version',
severity: 'info',
tags: ['tech', 'nginx'],
content: `id: nginx-version
info:
name: Nginx Version Detection
author: pdteam
severity: info
tags: tech,nginx
http:
- method: GET
path:
- "{{BaseURL}}/"
matchers:
- type: regex
part: header
regex:
- "nginx/([\\d.]+)"
extractors:
- type: regex
part: header
group: 1
regex:
- "nginx/([\\d.]+)"
`,
},
}
export function getMockNucleiTemplateTree(): NucleiTemplateTreeResponse {
return {
roots: mockNucleiTemplateTree,
}
}
export function getMockNucleiTemplateContent(path: string): NucleiTemplateContent | undefined {
return mockNucleiTemplateContent[path]
}

View File

@@ -0,0 +1,154 @@
import type {
SearchResponse,
WebsiteSearchResult,
EndpointSearchResult,
AssetType,
} from '@/types/search.types'
import { mockWebsites } from './websites'
import { mockEndpoints } from './endpoints'
// 将 Website 转换为搜索结果格式
function websiteToSearchResult(website: typeof mockWebsites[0]): WebsiteSearchResult {
return {
id: website.id,
url: website.url,
host: website.host,
title: website.title,
technologies: website.tech || [],
statusCode: website.statusCode,
contentLength: website.contentLength,
contentType: website.contentType,
webserver: website.webserver,
location: website.location,
vhost: website.vhost,
responseHeaders: {},
responseBody: website.responseBody || '',
createdAt: website.createdAt,
targetId: website.target ?? 1,
vulnerabilities: [],
}
}
// 将 Endpoint 转换为搜索结果格式
function endpointToSearchResult(endpoint: typeof mockEndpoints[0]): EndpointSearchResult {
return {
id: endpoint.id,
url: endpoint.url,
host: endpoint.host || '',
title: endpoint.title,
technologies: endpoint.tech || [],
statusCode: endpoint.statusCode,
contentLength: endpoint.contentLength,
contentType: endpoint.contentType || '',
webserver: endpoint.webserver || '',
location: endpoint.location || '',
vhost: null,
responseHeaders: {},
responseBody: '',
createdAt: endpoint.createdAt ?? null,
targetId: 1,
matchedGfPatterns: endpoint.gfPatterns || [],
}
}
// 解析搜索表达式
function parseSearchQuery(query: string): { field: string; operator: string; value: string }[] {
const conditions: { field: string; operator: string; value: string }[] = []
// 简单解析field="value" 或 field=="value" 或 field!="value"
const regex = /(\w+)(==|!=|=)"([^"]+)"/g
let match
while ((match = regex.exec(query)) !== null) {
conditions.push({
field: match[1],
operator: match[2],
value: match[3],
})
}
return conditions
}
// 检查记录是否匹配条件
function matchesConditions(
record: WebsiteSearchResult | EndpointSearchResult,
conditions: { field: string; operator: string; value: string }[]
): boolean {
if (conditions.length === 0) return true
return conditions.every(cond => {
let fieldValue: string | number | null = null
switch (cond.field) {
case 'host':
fieldValue = record.host
break
case 'url':
fieldValue = record.url
break
case 'title':
fieldValue = record.title
break
case 'tech':
fieldValue = record.technologies.join(',')
break
case 'status':
fieldValue = String(record.statusCode)
break
default:
return true
}
if (fieldValue === null) return false
const strValue = String(fieldValue).toLowerCase()
const searchValue = cond.value.toLowerCase()
switch (cond.operator) {
case '=':
return strValue.includes(searchValue)
case '==':
return strValue === searchValue
case '!=':
return !strValue.includes(searchValue)
default:
return true
}
})
}
export function getMockSearchResults(params: {
q?: string
asset_type?: AssetType
page?: number
pageSize?: number
}): SearchResponse {
const { q = '', asset_type = 'website', page = 1, pageSize = 10 } = params
const conditions = parseSearchQuery(q)
let results: (WebsiteSearchResult | EndpointSearchResult)[]
if (asset_type === 'website') {
results = mockWebsites
.map(websiteToSearchResult)
.filter(r => matchesConditions(r, conditions))
} else {
results = mockEndpoints
.map(endpointToSearchResult)
.filter(r => matchesConditions(r, conditions))
}
const total = results.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const paginatedResults = results.slice(start, start + pageSize)
return {
results: paginatedResults,
total,
page,
pageSize,
totalPages,
assetType: asset_type,
}
}

View File

@@ -0,0 +1,100 @@
import type { SystemLogResponse, LogFilesResponse, LogFile } from '@/types/system-log.types'
export const mockLogFiles: LogFile[] = [
{
filename: 'xingrin.log',
category: 'system',
size: 1234567,
modifiedAt: '2024-12-28T10:00:00Z',
},
{
filename: 'xingrin-error.log',
category: 'error',
size: 45678,
modifiedAt: '2024-12-28T09:30:00Z',
},
{
filename: 'worker.log',
category: 'system',
size: 234567,
modifiedAt: '2024-12-28T10:00:00Z',
},
{
filename: 'celery.log',
category: 'system',
size: 567890,
modifiedAt: '2024-12-28T09:45:00Z',
},
{
filename: 'nginx-access.log',
category: 'system',
size: 12345678,
modifiedAt: '2024-12-28T10:00:00Z',
},
{
filename: 'nginx-error.log',
category: 'error',
size: 23456,
modifiedAt: '2024-12-28T08:00:00Z',
},
]
export const mockSystemLogContent = `[2024-12-28 10:00:00] INFO: Server started on port 8000
[2024-12-28 10:00:01] INFO: Database connection established
[2024-12-28 10:00:02] INFO: Redis connection established
[2024-12-28 10:00:03] INFO: Worker node registered: local-worker-1
[2024-12-28 10:00:05] INFO: Celery worker started with 4 concurrent tasks
[2024-12-28 10:01:00] INFO: New scan task created: scan-001
[2024-12-28 10:01:01] INFO: Task scan-001 assigned to worker local-worker-1
[2024-12-28 10:01:05] INFO: Subdomain enumeration started for target: acme.com
[2024-12-28 10:02:30] INFO: Found 45 subdomains for acme.com
[2024-12-28 10:02:31] INFO: Port scanning started for 45 hosts
[2024-12-28 10:05:00] INFO: Port scanning completed, found 123 open ports
[2024-12-28 10:05:01] INFO: HTTP probing started for 123 endpoints
[2024-12-28 10:08:00] INFO: HTTP probing completed, found 89 live websites
[2024-12-28 10:08:01] INFO: Fingerprint detection started
[2024-12-28 10:10:00] INFO: Fingerprint detection completed
[2024-12-28 10:10:01] INFO: Vulnerability scanning started with nuclei
[2024-12-28 10:15:00] INFO: Vulnerability scanning completed, found 5 vulnerabilities
[2024-12-28 10:15:01] INFO: Scan task scan-001 completed successfully
[2024-12-28 10:15:02] INFO: Results saved to database
[2024-12-28 10:15:03] INFO: Notification sent to Discord webhook`
export const mockErrorLogContent = `[2024-12-28 08:30:00] ERROR: Connection refused: Redis server not responding
[2024-12-28 08:30:01] ERROR: Retrying Redis connection in 5 seconds...
[2024-12-28 08:30:06] INFO: Redis connection recovered
[2024-12-28 09:15:00] WARNING: High memory usage detected (85%)
[2024-12-28 09:15:01] INFO: Running garbage collection
[2024-12-28 09:15:05] INFO: Memory usage reduced to 62%
[2024-12-28 09:30:00] ERROR: Worker node disconnected: remote-worker-2
[2024-12-28 09:30:01] WARNING: Reassigning 3 tasks from remote-worker-2
[2024-12-28 09:30:05] INFO: Tasks reassigned successfully`
export function getMockLogFiles(): LogFilesResponse {
return {
files: mockLogFiles,
}
}
export function getMockSystemLogs(params?: {
file?: string
lines?: number
}): SystemLogResponse {
const filename = params?.file || 'xingrin.log'
const lines = params?.lines || 100
let content: string
if (filename.includes('error')) {
content = mockErrorLogContent
} else {
content = mockSystemLogContent
}
// 模拟行数限制
const contentLines = content.split('\n')
const limitedContent = contentLines.slice(-lines).join('\n')
return {
content: limitedContent,
}
}

149
frontend/mock/data/tools.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { Tool, GetToolsResponse } from '@/types/tool.types'
export const mockTools: Tool[] = [
{
id: 1,
name: 'subfinder',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/subfinder',
version: 'v2.6.3',
description: 'Fast passive subdomain enumeration tool.',
categoryNames: ['subdomain', 'recon'],
directory: '/opt/tools/subfinder',
installCommand: 'go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest',
updateCommand: 'go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest',
versionCommand: 'subfinder -version',
createdAt: '2024-12-20T10:00:00Z',
updatedAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
name: 'httpx',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/httpx',
version: 'v1.6.0',
description: 'Fast and multi-purpose HTTP toolkit.',
categoryNames: ['http', 'recon'],
directory: '/opt/tools/httpx',
installCommand: 'go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest',
updateCommand: 'go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest',
versionCommand: 'httpx -version',
createdAt: '2024-12-20T10:01:00Z',
updatedAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
name: 'nuclei',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/nuclei',
version: 'v3.1.0',
description: 'Fast and customizable vulnerability scanner.',
categoryNames: ['vulnerability'],
directory: '/opt/tools/nuclei',
installCommand: 'go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest',
updateCommand: 'go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest',
versionCommand: 'nuclei -version',
createdAt: '2024-12-20T10:02:00Z',
updatedAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
name: 'naabu',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/naabu',
version: 'v2.2.1',
description: 'Fast port scanner written in go.',
categoryNames: ['port', 'network'],
directory: '/opt/tools/naabu',
installCommand: 'go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest',
updateCommand: 'go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest',
versionCommand: 'naabu -version',
createdAt: '2024-12-20T10:03:00Z',
updatedAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
name: 'katana',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/katana',
version: 'v1.0.4',
description: 'Next-generation crawling and spidering framework.',
categoryNames: ['crawler', 'recon'],
directory: '/opt/tools/katana',
installCommand: 'go install github.com/projectdiscovery/katana/cmd/katana@latest',
updateCommand: 'go install github.com/projectdiscovery/katana/cmd/katana@latest',
versionCommand: 'katana -version',
createdAt: '2024-12-20T10:04:00Z',
updatedAt: '2024-12-28T10:04:00Z',
},
{
id: 6,
name: 'ffuf',
type: 'opensource',
repoUrl: 'https://github.com/ffuf/ffuf',
version: 'v2.1.0',
description: 'Fast web fuzzer written in Go.',
categoryNames: ['directory', 'fuzzer'],
directory: '/opt/tools/ffuf',
installCommand: 'go install github.com/ffuf/ffuf/v2@latest',
updateCommand: 'go install github.com/ffuf/ffuf/v2@latest',
versionCommand: 'ffuf -V',
createdAt: '2024-12-20T10:05:00Z',
updatedAt: '2024-12-28T10:05:00Z',
},
{
id: 7,
name: 'amass',
type: 'opensource',
repoUrl: 'https://github.com/owasp-amass/amass',
version: 'v4.2.0',
description: 'In-depth attack surface mapping and asset discovery.',
categoryNames: ['subdomain', 'recon'],
directory: '/opt/tools/amass',
installCommand: 'go install -v github.com/owasp-amass/amass/v4/...@master',
updateCommand: 'go install -v github.com/owasp-amass/amass/v4/...@master',
versionCommand: 'amass -version',
createdAt: '2024-12-20T10:06:00Z',
updatedAt: '2024-12-28T10:06:00Z',
},
{
id: 8,
name: 'xingfinger',
type: 'custom',
repoUrl: '',
version: '1.0.0',
description: '自定义指纹识别工具',
categoryNames: ['recon'],
directory: '/opt/tools/xingfinger',
installCommand: '',
updateCommand: '',
versionCommand: '',
createdAt: '2024-12-20T10:07:00Z',
updatedAt: '2024-12-28T10:07:00Z',
},
]
export function getMockTools(params?: {
page?: number
pageSize?: number
}): GetToolsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const total = mockTools.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const tools = mockTools.slice(start, start + pageSize)
return {
tools,
total,
page,
pageSize,
totalPages,
}
}
export function getMockToolById(id: number): Tool | undefined {
return mockTools.find(t => t.id === id)
}

View File

@@ -0,0 +1,119 @@
import type { Wordlist, GetWordlistsResponse } from '@/types/wordlist.types'
export const mockWordlists: Wordlist[] = [
{
id: 1,
name: 'common-dirs.txt',
description: '常用目录字典',
fileSize: 45678,
lineCount: 4567,
fileHash: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6',
createdAt: '2024-12-20T10:00:00Z',
updatedAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
name: 'subdomains-top1million.txt',
description: 'Top 100万子域名字典',
fileSize: 12345678,
lineCount: 1000000,
fileHash: 'b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7',
createdAt: '2024-12-20T10:01:00Z',
updatedAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
name: 'api-endpoints.txt',
description: 'API 端点字典',
fileSize: 23456,
lineCount: 2345,
fileHash: 'c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8',
createdAt: '2024-12-20T10:02:00Z',
updatedAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
name: 'params.txt',
description: '常用参数名字典',
fileSize: 8901,
lineCount: 890,
fileHash: 'd4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9',
createdAt: '2024-12-20T10:03:00Z',
updatedAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
name: 'sensitive-files.txt',
description: '敏感文件字典',
fileSize: 5678,
lineCount: 567,
fileHash: 'e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0',
createdAt: '2024-12-20T10:04:00Z',
updatedAt: '2024-12-28T10:04:00Z',
},
{
id: 6,
name: 'raft-large-directories.txt',
description: 'RAFT 大型目录字典',
fileSize: 987654,
lineCount: 98765,
fileHash: 'f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1',
createdAt: '2024-12-20T10:05:00Z',
updatedAt: '2024-12-28T10:05:00Z',
},
]
export const mockWordlistContent = `admin
api
backup
config
dashboard
debug
dev
docs
download
files
images
js
login
logs
manager
private
public
static
test
upload
users
v1
v2
wp-admin
wp-content`
export function getMockWordlists(params?: {
page?: number
pageSize?: number
}): GetWordlistsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const total = mockWordlists.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = mockWordlists.slice(start, start + pageSize)
return {
results,
total,
page,
pageSize,
totalPages,
}
}
export function getMockWordlistById(id: number): Wordlist | undefined {
return mockWordlists.find(w => w.id === id)
}
export function getMockWordlistContent(): string {
return mockWordlistContent
}

View File

@@ -105,3 +105,80 @@ export {
getMockScheduledScans,
getMockScheduledScanById,
} from './data/scheduled-scans'
// Directories
export {
mockDirectories,
getMockDirectories,
getMockDirectoryById,
} from './data/directories'
// Fingerprints
export {
mockEholeFingerprints,
mockGobyFingerprints,
mockWappalyzerFingerprints,
mockFingersFingerprints,
mockFingerPrintHubFingerprints,
mockARLFingerprints,
mockFingerprintStats,
getMockEholeFingerprints,
getMockGobyFingerprints,
getMockWappalyzerFingerprints,
getMockFingersFingerprints,
getMockFingerPrintHubFingerprints,
getMockARLFingerprints,
getMockFingerprintStats,
} from './data/fingerprints'
// IP Addresses
export {
mockIPAddresses,
getMockIPAddresses,
getMockIPAddressByIP,
} from './data/ip-addresses'
// Search
export {
getMockSearchResults,
} from './data/search'
// Tools
export {
mockTools,
getMockTools,
getMockToolById,
} from './data/tools'
// Wordlists
export {
mockWordlists,
mockWordlistContent,
getMockWordlists,
getMockWordlistById,
getMockWordlistContent,
} from './data/wordlists'
// Nuclei Templates
export {
mockNucleiTemplateTree,
mockNucleiTemplateContent,
getMockNucleiTemplateTree,
getMockNucleiTemplateContent,
} from './data/nuclei-templates'
// System Logs
export {
mockLogFiles,
mockSystemLogContent,
mockErrorLogContent,
getMockLogFiles,
getMockSystemLogs,
} from './data/system-logs'
// Notification Settings
export {
mockNotificationSettings,
getMockNotificationSettings,
updateMockNotificationSettings,
} from './data/notification-settings'

View File

@@ -113,3 +113,40 @@ export async function getScanStatistics(): Promise<ScanStatistics> {
const res = await api.get<ScanStatistics>('/scans/statistics/')
return res.data
}
/**
* Scan log entry type
*/
export interface ScanLog {
id: number
level: 'info' | 'warning' | 'error'
content: string
createdAt: string
}
/**
* Get scan logs response type
*/
export interface GetScanLogsResponse {
results: ScanLog[]
hasMore: boolean
}
/**
* Get scan logs params type
*/
export interface GetScanLogsParams {
afterId?: number
limit?: number
}
/**
* Get scan logs
* @param scanId - Scan ID
* @param params - Query parameters (afterId for cursor, limit for max results)
* @returns Scan logs with hasMore indicator
*/
export async function getScanLogs(scanId: number, params?: GetScanLogsParams): Promise<GetScanLogsResponse> {
const res = await api.get<GetScanLogsResponse>(`/scans/${scanId}/logs/`, { params })
return res.data
}

View File

@@ -42,34 +42,17 @@ export class SearchService {
/**
* 导出搜索结果为 CSV
* GET /api/assets/search/export/
*
* 使用浏览器原生下载,支持显示下载进度
*/
static async exportCSV(query: string, assetType: AssetType): Promise<void> {
const queryParams = new URLSearchParams()
queryParams.append('q', query)
queryParams.append('asset_type', assetType)
const response = await api.get(
`/assets/search/export/?${queryParams.toString()}`,
{ responseType: 'blob' }
)
// 从响应头获取文件名
const contentDisposition = response.headers?.['content-disposition']
let filename = `search_${assetType}_${new Date().toISOString().slice(0, 10)}.csv`
if (contentDisposition) {
const match = contentDisposition.match(/filename="?([^"]+)"?/)
if (match) filename = match[1]
}
// 创建下载链接
const blob = new Blob([response.data as BlobPart], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
// 直接打开下载链接,使用浏览器原生下载管理器
// 这样可以显示下载进度,且不会阻塞页面
const downloadUrl = `/api/assets/search/export/?${queryParams.toString()}`
window.open(downloadUrl, '_blank')
}
}