mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 19:53:11 +08:00
Compare commits
11 Commits
v1.3.6-dev
...
v1.3.12-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb53c9f85 | ||
|
|
8dd3f0536e | ||
|
|
8a8062a12d | ||
|
|
55908a2da5 | ||
|
|
22a7d4f091 | ||
|
|
f287f18134 | ||
|
|
de27230b7a | ||
|
|
15a6295189 | ||
|
|
674acdac66 | ||
|
|
c59152bedf | ||
|
|
b4037202dc |
176
.github/workflows/docker-build.yml
vendored
176
.github/workflows/docker-build.yml
vendored
@@ -19,7 +19,8 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# AMD64 构建(原生 x64 runner)
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -27,43 +28,30 @@ jobs:
|
||||
- image: xingrin-server
|
||||
dockerfile: docker/server/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- image: xingrin-frontend
|
||||
dockerfile: docker/frontend/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64 # ARM64 构建时 Next.js 在 QEMU 下会崩溃
|
||||
- image: xingrin-worker
|
||||
dockerfile: docker/worker/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- image: xingrin-nginx
|
||||
dockerfile: docker/nginx/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- image: xingrin-agent
|
||||
dockerfile: docker/agent/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- image: xingrin-postgres
|
||||
dockerfile: docker/postgres/Dockerfile
|
||||
context: docker/postgres
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Free disk space (for large builds like worker)
|
||||
- name: Free disk space
|
||||
run: |
|
||||
echo "=== Before cleanup ==="
|
||||
df -h
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
sudo docker image prune -af
|
||||
echo "=== After cleanup ==="
|
||||
df -h
|
||||
|
||||
- name: Generate SSL certificates for nginx build
|
||||
if: matrix.image == 'xingrin-nginx'
|
||||
@@ -73,10 +61,6 @@ jobs:
|
||||
-keyout docker/nginx/ssl/privkey.pem \
|
||||
-out docker/nginx/ssl/fullchain.pem \
|
||||
-subj "/CN=localhost"
|
||||
echo "SSL certificates generated for CI build"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -87,7 +71,120 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get version from git tag
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "VERSION=dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build and push AMD64
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:${{ steps.version.outputs.VERSION }}-amd64
|
||||
build-args: IMAGE_TAG=${{ steps.version.outputs.VERSION }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:cache-amd64
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:cache-amd64,mode=max
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
# ARM64 构建(原生 ARM64 runner)
|
||||
build-arm64:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- image: xingrin-server
|
||||
dockerfile: docker/server/Dockerfile
|
||||
context: .
|
||||
- image: xingrin-frontend
|
||||
dockerfile: docker/frontend/Dockerfile
|
||||
context: .
|
||||
- image: xingrin-worker
|
||||
dockerfile: docker/worker/Dockerfile
|
||||
context: .
|
||||
- image: xingrin-nginx
|
||||
dockerfile: docker/nginx/Dockerfile
|
||||
context: .
|
||||
- image: xingrin-agent
|
||||
dockerfile: docker/agent/Dockerfile
|
||||
context: .
|
||||
- image: xingrin-postgres
|
||||
dockerfile: docker/postgres/Dockerfile
|
||||
context: docker/postgres
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate SSL certificates for nginx build
|
||||
if: matrix.image == 'xingrin-nginx'
|
||||
run: |
|
||||
mkdir -p docker/nginx/ssl
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout docker/nginx/ssl/privkey.pem \
|
||||
-out docker/nginx/ssl/fullchain.pem \
|
||||
-subj "/CN=localhost"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "VERSION=dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build and push ARM64
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:${{ steps.version.outputs.VERSION }}-arm64
|
||||
build-args: IMAGE_TAG=${{ steps.version.outputs.VERSION }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:cache-arm64
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:cache-arm64,mode=max
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
# 合并多架构 manifest
|
||||
merge-manifests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-amd64, build-arm64]
|
||||
strategy:
|
||||
matrix:
|
||||
image:
|
||||
- xingrin-server
|
||||
- xingrin-frontend
|
||||
- xingrin-worker
|
||||
- xingrin-nginx
|
||||
- xingrin-agent
|
||||
- xingrin-postgres
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
@@ -98,28 +195,27 @@ jobs:
|
||||
echo "IS_RELEASE=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:${{ steps.version.outputs.VERSION }}
|
||||
${{ steps.version.outputs.IS_RELEASE == 'true' && format('{0}/{1}:latest', env.IMAGE_PREFIX, matrix.image) || '' }}
|
||||
build-args: |
|
||||
IMAGE_TAG=${{ steps.version.outputs.VERSION }}
|
||||
cache-from: type=gha,scope=${{ matrix.image }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
- name: Create and push multi-arch manifest
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.VERSION }}
|
||||
IMAGE=${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
|
||||
|
||||
docker manifest create ${IMAGE}:${VERSION} \
|
||||
${IMAGE}:${VERSION}-amd64 \
|
||||
${IMAGE}:${VERSION}-arm64
|
||||
docker manifest push ${IMAGE}:${VERSION}
|
||||
|
||||
if [[ "${{ steps.version.outputs.IS_RELEASE }}" == "true" ]]; then
|
||||
docker manifest create ${IMAGE}:latest \
|
||||
${IMAGE}:${VERSION}-amd64 \
|
||||
${IMAGE}:${VERSION}-arm64
|
||||
docker manifest push ${IMAGE}:latest
|
||||
fi
|
||||
|
||||
# 所有镜像构建成功后,更新 VERSION 文件
|
||||
# 根据 tag 所在的分支更新对应分支的 VERSION 文件
|
||||
# 更新 VERSION 文件
|
||||
update-version:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: merge-manifests
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -242,13 +242,9 @@ sudo ./uninstall.sh
|
||||
|
||||
## 🤝 反馈与贡献
|
||||
|
||||
- 🐛 **如果发现 Bug** 可以点击右边链接进行提交 [Issue](https://github.com/yyhuni/xingrin/issues)
|
||||
- 💡 **有新想法,比如UI设计,功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues)
|
||||
- 💡 **发现 Bug,有新想法,比如UI设计,功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues) 或者公众号私信
|
||||
|
||||
## 📧 联系
|
||||
- 目前版本就我个人使用,可能会有很多边界问题
|
||||
- 如有问题,建议,其他,优先提交[Issue](https://github.com/yyhuni/xingrin/issues),也可以直接给我的公众号发消息,我都会回复的
|
||||
|
||||
- 微信公众号: **塔罗安全学苑**
|
||||
|
||||
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">
|
||||
|
||||
@@ -1,106 +1,6 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AssetConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.asset'
|
||||
|
||||
def ready(self):
|
||||
# 导入所有模型以确保Django发现并注册
|
||||
from . import models
|
||||
|
||||
# 启用 pg_trgm 扩展(用于文本模糊搜索索引)
|
||||
# 用于已有数据库升级场景
|
||||
self._ensure_pg_trgm_extension()
|
||||
|
||||
# 验证 pg_ivm 扩展是否可用(用于 IMMV 增量维护)
|
||||
self._verify_pg_ivm_extension()
|
||||
|
||||
def _ensure_pg_trgm_extension(self):
|
||||
"""
|
||||
确保 pg_trgm 扩展已启用。
|
||||
该扩展用于 response_body 和 response_headers 字段的 GIN 索引,
|
||||
支持高效的文本模糊搜索。
|
||||
"""
|
||||
from django.db import connection
|
||||
|
||||
# 检查是否为 PostgreSQL 数据库
|
||||
if connection.vendor != 'postgresql':
|
||||
logger.debug("跳过 pg_trgm 扩展:当前数据库不是 PostgreSQL")
|
||||
return
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
logger.debug("pg_trgm 扩展已启用")
|
||||
except Exception as e:
|
||||
# 记录错误但不阻止应用启动
|
||||
# 常见原因:权限不足(需要超级用户权限)
|
||||
logger.warning(
|
||||
"无法创建 pg_trgm 扩展: %s。"
|
||||
"这可能导致 response_body 和 response_headers 字段的 GIN 索引无法正常工作。"
|
||||
"请手动执行: CREATE EXTENSION IF NOT EXISTS pg_trgm;",
|
||||
str(e)
|
||||
)
|
||||
|
||||
def _verify_pg_ivm_extension(self):
|
||||
"""
|
||||
验证 pg_ivm 扩展是否可用。
|
||||
pg_ivm 用于 IMMV(增量维护物化视图),是系统必需的扩展。
|
||||
如果不可用,将记录错误并退出。
|
||||
"""
|
||||
from django.db import connection
|
||||
|
||||
# 检查是否为 PostgreSQL 数据库
|
||||
if connection.vendor != 'postgresql':
|
||||
logger.debug("跳过 pg_ivm 验证:当前数据库不是 PostgreSQL")
|
||||
return
|
||||
|
||||
# 跳过某些管理命令(如 migrate、makemigrations)
|
||||
import sys
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ('migrate', 'makemigrations', 'collectstatic', 'check'):
|
||||
logger.debug("跳过 pg_ivm 验证:当前为管理命令")
|
||||
return
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 检查 pg_ivm 扩展是否已安装
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_ivm'
|
||||
""")
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
if count > 0:
|
||||
logger.info("✓ pg_ivm 扩展已启用")
|
||||
else:
|
||||
# 尝试创建扩展
|
||||
try:
|
||||
cursor.execute("CREATE EXTENSION IF NOT EXISTS pg_ivm;")
|
||||
logger.info("✓ pg_ivm 扩展已创建并启用")
|
||||
except Exception as create_error:
|
||||
logger.error(
|
||||
"=" * 60 + "\n"
|
||||
"错误: pg_ivm 扩展未安装\n"
|
||||
"=" * 60 + "\n"
|
||||
"pg_ivm 是系统必需的扩展,用于增量维护物化视图。\n\n"
|
||||
"请在 PostgreSQL 服务器上安装 pg_ivm:\n"
|
||||
" curl -sSL https://raw.githubusercontent.com/yyhuni/xingrin/main/docker/scripts/install-pg-ivm.sh | sudo bash\n\n"
|
||||
"或手动安装:\n"
|
||||
" 1. apt install build-essential postgresql-server-dev-15 git\n"
|
||||
" 2. git clone https://github.com/sraoss/pg_ivm.git && cd pg_ivm && make && make install\n"
|
||||
" 3. 在 postgresql.conf 中添加: shared_preload_libraries = 'pg_ivm'\n"
|
||||
" 4. 重启 PostgreSQL\n"
|
||||
"=" * 60
|
||||
)
|
||||
# 在生产环境中退出,开发环境中仅警告
|
||||
from django.conf import settings
|
||||
if not settings.DEBUG:
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"pg_ivm 扩展验证失败: {e}")
|
||||
|
||||
@@ -18,7 +18,13 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. 确保 pg_ivm 扩展已启用
|
||||
# 1. 确保 pg_trgm 扩展已启用(用于文本模糊搜索索引)
|
||||
migrations.RunSQL(
|
||||
sql="CREATE EXTENSION IF NOT EXISTS pg_trgm;",
|
||||
reverse_sql="-- pg_trgm extension kept for other uses"
|
||||
),
|
||||
|
||||
# 2. 确保 pg_ivm 扩展已启用(用于 IMMV 增量维护)
|
||||
migrations.RunSQL(
|
||||
sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;",
|
||||
reverse_sql="-- pg_ivm extension kept for other uses"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, List, Dict, Any, Tuple, Literal
|
||||
from typing import Optional, List, Dict, Any, Tuple, Literal, Iterator
|
||||
|
||||
from django.db import connection
|
||||
|
||||
@@ -369,13 +369,14 @@ 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: 结果总数
|
||||
@@ -389,6 +390,8 @@ class AssetSearchService:
|
||||
|
||||
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:
|
||||
@@ -399,15 +402,17 @@ 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: 单条搜索结果
|
||||
@@ -418,22 +423,38 @@ class AssetSearchService:
|
||||
view_name = VIEW_MAPPING.get(asset_type, 'asset_search_view')
|
||||
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
|
||||
"""
|
||||
# 使用 OFFSET/LIMIT 分批查询(Django 不支持命名游标)
|
||||
offset = 0
|
||||
|
||||
try:
|
||||
# 使用服务端游标,避免一次性加载所有数据到内存
|
||||
with connection.cursor(name='export_cursor') as cursor:
|
||||
cursor.itersize = batch_size
|
||||
cursor.execute(sql, params)
|
||||
columns = [col[0] for col in cursor.description]
|
||||
while True:
|
||||
sql = f"""
|
||||
SELECT {select_fields}
|
||||
FROM {view_name}
|
||||
WHERE {where_clause}
|
||||
ORDER BY 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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Asset 应用的任务模块
|
||||
|
||||
注意:物化视图刷新已移至 APScheduler 定时任务(apps.engine.scheduler)
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
@@ -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):
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
|
||||
from apps.common.response_helpers import success_response, error_response
|
||||
@@ -285,7 +284,7 @@ class AssetSearchExportView(APIView):
|
||||
asset_type: 资产类型 ('website' 或 'endpoint',默认 'website')
|
||||
|
||||
Response:
|
||||
CSV 文件流(使用服务端游标,支持大数据量导出)
|
||||
CSV 文件(带 Content-Length,支持浏览器显示下载进度)
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -313,8 +312,8 @@ class AssetSearchExportView(APIView):
|
||||
return headers, formatters
|
||||
|
||||
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()
|
||||
@@ -347,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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
- UTF-8 BOM(Excel 兼容)
|
||||
- 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
|
||||
|
||||
@@ -115,7 +115,7 @@ def initiate_scan_flow(
|
||||
# ==================== Task 2: 获取引擎配置 ====================
|
||||
from apps.scan.models import Scan
|
||||
scan = Scan.objects.get(id=scan_id)
|
||||
engine_config = scan.merged_configuration
|
||||
engine_config = scan.yaml_configuration
|
||||
|
||||
# 使用 engine_names 进行显示
|
||||
display_engine_name = ', '.join(scan.engine_names) if scan.engine_names else engine_name
|
||||
|
||||
@@ -57,7 +57,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('engine_ids', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, help_text='引擎 ID 列表', size=None)),
|
||||
('engine_names', models.JSONField(default=list, help_text='引擎名称列表,如 ["引擎A", "引擎B"]')),
|
||||
('merged_configuration', models.TextField(default='', help_text='合并后的 YAML 配置')),
|
||||
('yaml_configuration', models.TextField(default='', help_text='YAML 格式的扫描配置')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='任务创建时间')),
|
||||
('stopped_at', models.DateTimeField(blank=True, help_text='扫描结束时间', null=True)),
|
||||
('status', models.CharField(choices=[('cancelled', '已取消'), ('completed', '已完成'), ('failed', '失败'), ('initiated', '初始化'), ('running', '运行中')], db_index=True, default='initiated', help_text='任务状态', max_length=20)),
|
||||
@@ -97,7 +97,7 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(help_text='任务名称', max_length=200)),
|
||||
('engine_ids', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, help_text='引擎 ID 列表', size=None)),
|
||||
('engine_names', models.JSONField(default=list, help_text='引擎名称列表,如 ["引擎A", "引擎B"]')),
|
||||
('merged_configuration', models.TextField(default='', help_text='合并后的 YAML 配置')),
|
||||
('yaml_configuration', models.TextField(default='', help_text='YAML 格式的扫描配置')),
|
||||
('cron_expression', models.CharField(default='0 2 * * *', help_text='Cron 表达式,格式:分 时 日 月 周', max_length=100)),
|
||||
('is_enabled', models.BooleanField(db_index=True, default=True, help_text='是否启用')),
|
||||
('run_count', models.IntegerField(default=0, help_text='已执行次数')),
|
||||
|
||||
@@ -30,9 +30,9 @@ class Scan(models.Model):
|
||||
default=list,
|
||||
help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
|
||||
)
|
||||
merged_configuration = models.TextField(
|
||||
yaml_configuration = models.TextField(
|
||||
default='',
|
||||
help_text='合并后的 YAML 配置'
|
||||
help_text='YAML 格式的扫描配置'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text='任务创建时间')
|
||||
@@ -136,9 +136,9 @@ class ScheduledScan(models.Model):
|
||||
default=list,
|
||||
help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
|
||||
)
|
||||
merged_configuration = models.TextField(
|
||||
yaml_configuration = models.TextField(
|
||||
default='',
|
||||
help_text='合并后的 YAML 配置'
|
||||
help_text='YAML 格式的扫描配置'
|
||||
)
|
||||
|
||||
# 关联的组织(组织扫描模式:执行时动态获取组织下所有目标)
|
||||
|
||||
@@ -104,7 +104,7 @@ class DjangoScanRepository:
|
||||
target: Target,
|
||||
engine_ids: List[int],
|
||||
engine_names: List[str],
|
||||
merged_configuration: str,
|
||||
yaml_configuration: str,
|
||||
results_dir: str,
|
||||
status: ScanStatus = ScanStatus.INITIATED
|
||||
) -> Scan:
|
||||
@@ -115,7 +115,7 @@ class DjangoScanRepository:
|
||||
target: 扫描目标
|
||||
engine_ids: 引擎 ID 列表
|
||||
engine_names: 引擎名称列表
|
||||
merged_configuration: 合并后的 YAML 配置
|
||||
yaml_configuration: YAML 格式的扫描配置
|
||||
results_dir: 结果目录
|
||||
status: 初始状态
|
||||
|
||||
@@ -126,7 +126,7 @@ class DjangoScanRepository:
|
||||
target=target,
|
||||
engine_ids=engine_ids,
|
||||
engine_names=engine_names,
|
||||
merged_configuration=merged_configuration,
|
||||
yaml_configuration=yaml_configuration,
|
||||
results_dir=results_dir,
|
||||
status=status,
|
||||
container_ids=[]
|
||||
|
||||
@@ -31,7 +31,7 @@ class ScheduledScanDTO:
|
||||
name: str = ''
|
||||
engine_ids: List[int] = None # 多引擎支持
|
||||
engine_names: List[str] = None # 引擎名称列表
|
||||
merged_configuration: str = '' # 合并后的配置
|
||||
yaml_configuration: str = '' # YAML 格式的扫描配置
|
||||
organization_id: Optional[int] = None # 组织扫描模式
|
||||
target_id: Optional[int] = None # 目标扫描模式
|
||||
cron_expression: Optional[str] = None
|
||||
@@ -114,7 +114,7 @@ class DjangoScheduledScanRepository:
|
||||
name=dto.name,
|
||||
engine_ids=dto.engine_ids,
|
||||
engine_names=dto.engine_names,
|
||||
merged_configuration=dto.merged_configuration,
|
||||
yaml_configuration=dto.yaml_configuration,
|
||||
organization_id=dto.organization_id, # 组织扫描模式
|
||||
target_id=dto.target_id if not dto.organization_id else None, # 目标扫描模式
|
||||
cron_expression=dto.cron_expression,
|
||||
@@ -147,8 +147,8 @@ class DjangoScheduledScanRepository:
|
||||
scheduled_scan.engine_ids = dto.engine_ids
|
||||
if dto.engine_names is not None:
|
||||
scheduled_scan.engine_names = dto.engine_names
|
||||
if dto.merged_configuration is not None:
|
||||
scheduled_scan.merged_configuration = dto.merged_configuration
|
||||
if dto.yaml_configuration is not None:
|
||||
scheduled_scan.yaml_configuration = dto.yaml_configuration
|
||||
if dto.cron_expression is not None:
|
||||
scheduled_scan.cron_expression = dto.cron_expression
|
||||
if dto.is_enabled is not None:
|
||||
|
||||
@@ -1,9 +1,71 @@
|
||||
from rest_framework import serializers
|
||||
from django.db.models import Count
|
||||
import yaml
|
||||
|
||||
from .models import Scan, ScheduledScan
|
||||
|
||||
|
||||
# ==================== 通用验证 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 配置格式,包括检测重复 key"""
|
||||
import yaml
|
||||
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("configuration 不能为空")
|
||||
|
||||
try:
|
||||
# 使用自定义 Loader 检测重复 key
|
||||
yaml.load(value, Loader=DuplicateKeyLoader)
|
||||
except yaml.YAMLError as e:
|
||||
raise serializers.ValidationError(f"无效的 YAML 格式: {str(e)}")
|
||||
|
||||
return value
|
||||
|
||||
def validate_engine_ids(self, value):
|
||||
"""验证引擎 ID 列表"""
|
||||
if not value:
|
||||
raise serializers.ValidationError("engine_ids 不能为空,请至少选择一个扫描引擎")
|
||||
return value
|
||||
|
||||
def validate_engine_names(self, value):
|
||||
"""验证引擎名称列表"""
|
||||
if not value:
|
||||
raise serializers.ValidationError("engine_names 不能为空")
|
||||
return value
|
||||
|
||||
|
||||
# ==================== 扫描任务序列化器 ====================
|
||||
|
||||
|
||||
class ScanSerializer(serializers.ModelSerializer):
|
||||
"""扫描任务序列化器"""
|
||||
target_name = serializers.SerializerMethodField()
|
||||
@@ -82,12 +144,12 @@ class ScanHistorySerializer(serializers.ModelSerializer):
|
||||
return summary
|
||||
|
||||
|
||||
class QuickScanSerializer(serializers.Serializer):
|
||||
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
|
||||
"""
|
||||
快速扫描序列化器
|
||||
|
||||
功能:
|
||||
- 接收目标列表和引擎配置
|
||||
- 接收目标列表和 YAML 配置
|
||||
- 自动创建/获取目标
|
||||
- 立即发起扫描
|
||||
"""
|
||||
@@ -101,11 +163,24 @@ class QuickScanSerializer(serializers.Serializer):
|
||||
help_text='目标列表,每个目标包含 name 字段'
|
||||
)
|
||||
|
||||
# 扫描引擎 ID 列表
|
||||
# YAML 配置(必填)
|
||||
configuration = serializers.CharField(
|
||||
required=True,
|
||||
help_text='YAML 格式的扫描配置(必填)'
|
||||
)
|
||||
|
||||
# 扫描引擎 ID 列表(必填,用于记录和显示)
|
||||
engine_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=True,
|
||||
help_text='使用的扫描引擎 ID 列表 (必填)'
|
||||
help_text='使用的扫描引擎 ID 列表(必填)'
|
||||
)
|
||||
|
||||
# 引擎名称列表(必填,用于记录和显示)
|
||||
engine_names = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=True,
|
||||
help_text='引擎名称列表(必填)'
|
||||
)
|
||||
|
||||
def validate_targets(self, value):
|
||||
@@ -127,12 +202,6 @@ class QuickScanSerializer(serializers.Serializer):
|
||||
raise serializers.ValidationError(f"第 {idx + 1} 个目标的 name 不能为空")
|
||||
|
||||
return value
|
||||
|
||||
def validate_engine_ids(self, value):
|
||||
"""验证引擎 ID 列表"""
|
||||
if not value:
|
||||
raise serializers.ValidationError("engine_ids 不能为空")
|
||||
return value
|
||||
|
||||
|
||||
# ==================== 定时扫描序列化器 ====================
|
||||
@@ -171,7 +240,7 @@ class ScheduledScanSerializer(serializers.ModelSerializer):
|
||||
return 'organization' if obj.organization_id else 'target'
|
||||
|
||||
|
||||
class CreateScheduledScanSerializer(serializers.Serializer):
|
||||
class CreateScheduledScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
|
||||
"""创建定时扫描任务序列化器
|
||||
|
||||
扫描模式(二选一):
|
||||
@@ -180,9 +249,25 @@ class CreateScheduledScanSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
name = serializers.CharField(max_length=200, help_text='任务名称')
|
||||
|
||||
# YAML 配置(必填)
|
||||
configuration = serializers.CharField(
|
||||
required=True,
|
||||
help_text='YAML 格式的扫描配置(必填)'
|
||||
)
|
||||
|
||||
# 扫描引擎 ID 列表(必填,用于记录和显示)
|
||||
engine_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text='扫描引擎 ID 列表'
|
||||
required=True,
|
||||
help_text='扫描引擎 ID 列表(必填)'
|
||||
)
|
||||
|
||||
# 引擎名称列表(必填,用于记录和显示)
|
||||
engine_names = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=True,
|
||||
help_text='引擎名称列表(必填)'
|
||||
)
|
||||
|
||||
# 组织扫描模式
|
||||
@@ -206,11 +291,61 @@ class CreateScheduledScanSerializer(serializers.Serializer):
|
||||
)
|
||||
is_enabled = serializers.BooleanField(default=True, help_text='是否立即启用')
|
||||
|
||||
def validate_engine_ids(self, value):
|
||||
"""验证引擎 ID 列表"""
|
||||
if not value:
|
||||
raise serializers.ValidationError("engine_ids 不能为空")
|
||||
return value
|
||||
def validate(self, data):
|
||||
"""验证 organization_id 和 target_id 互斥"""
|
||||
organization_id = data.get('organization_id')
|
||||
target_id = data.get('target_id')
|
||||
|
||||
if not organization_id and not target_id:
|
||||
raise serializers.ValidationError('必须提供 organization_id 或 target_id 其中之一')
|
||||
|
||||
if organization_id and target_id:
|
||||
raise serializers.ValidationError('organization_id 和 target_id 只能提供其中之一')
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class InitiateScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
|
||||
"""发起扫描任务序列化器
|
||||
|
||||
扫描模式(二选一):
|
||||
- 组织扫描:提供 organization_id,扫描组织下所有目标
|
||||
- 目标扫描:提供 target_id,扫描单个目标
|
||||
"""
|
||||
|
||||
# YAML 配置(必填)
|
||||
configuration = serializers.CharField(
|
||||
required=True,
|
||||
help_text='YAML 格式的扫描配置(必填)'
|
||||
)
|
||||
|
||||
# 扫描引擎 ID 列表(必填)
|
||||
engine_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=True,
|
||||
help_text='扫描引擎 ID 列表(必填)'
|
||||
)
|
||||
|
||||
# 引擎名称列表(必填)
|
||||
engine_names = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=True,
|
||||
help_text='引擎名称列表(必填)'
|
||||
)
|
||||
|
||||
# 组织扫描模式
|
||||
organization_id = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text='组织 ID(组织扫描模式)'
|
||||
)
|
||||
|
||||
# 目标扫描模式
|
||||
target_id = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text='目标 ID(目标扫描模式)'
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""验证 organization_id 和 target_id 互斥"""
|
||||
|
||||
@@ -282,7 +282,7 @@ class ScanCreationService:
|
||||
targets: List[Target],
|
||||
engine_ids: List[int],
|
||||
engine_names: List[str],
|
||||
merged_configuration: str,
|
||||
yaml_configuration: str,
|
||||
scheduled_scan_name: str | None = None
|
||||
) -> List[Scan]:
|
||||
"""
|
||||
@@ -292,7 +292,7 @@ class ScanCreationService:
|
||||
targets: 目标列表
|
||||
engine_ids: 引擎 ID 列表
|
||||
engine_names: 引擎名称列表
|
||||
merged_configuration: 合并后的 YAML 配置
|
||||
yaml_configuration: YAML 格式的扫描配置
|
||||
scheduled_scan_name: 定时扫描任务名称(可选,用于通知显示)
|
||||
|
||||
Returns:
|
||||
@@ -312,7 +312,7 @@ class ScanCreationService:
|
||||
target=target,
|
||||
engine_ids=engine_ids,
|
||||
engine_names=engine_names,
|
||||
merged_configuration=merged_configuration,
|
||||
yaml_configuration=yaml_configuration,
|
||||
results_dir=scan_workspace_dir,
|
||||
status=ScanStatus.INITIATED,
|
||||
container_ids=[],
|
||||
|
||||
@@ -117,12 +117,12 @@ class ScanService:
|
||||
targets: List[Target],
|
||||
engine_ids: List[int],
|
||||
engine_names: List[str],
|
||||
merged_configuration: str,
|
||||
yaml_configuration: str,
|
||||
scheduled_scan_name: str | None = None
|
||||
) -> List[Scan]:
|
||||
"""批量创建扫描任务(委托给 ScanCreationService)"""
|
||||
return self.creation_service.create_scans(
|
||||
targets, engine_ids, engine_names, merged_configuration, scheduled_scan_name
|
||||
targets, engine_ids, engine_names, yaml_configuration, scheduled_scan_name
|
||||
)
|
||||
|
||||
# ==================== 状态管理方法(委托给 ScanStateService) ====================
|
||||
|
||||
@@ -54,7 +54,7 @@ class ScheduledScanService:
|
||||
|
||||
def create(self, dto: ScheduledScanDTO) -> ScheduledScan:
|
||||
"""
|
||||
创建定时扫描任务
|
||||
创建定时扫描任务(使用引擎 ID 合并配置)
|
||||
|
||||
流程:
|
||||
1. 验证参数
|
||||
@@ -88,7 +88,7 @@ class ScheduledScanService:
|
||||
|
||||
# 设置 DTO 的合并配置和引擎名称
|
||||
dto.engine_names = engine_names
|
||||
dto.merged_configuration = merged_configuration
|
||||
dto.yaml_configuration = merged_configuration
|
||||
|
||||
# 3. 创建数据库记录
|
||||
scheduled_scan = self.repo.create(dto)
|
||||
@@ -107,12 +107,49 @@ class ScheduledScanService:
|
||||
|
||||
return scheduled_scan
|
||||
|
||||
def _validate_create_dto(self, dto: ScheduledScanDTO) -> None:
|
||||
"""验证创建 DTO"""
|
||||
from apps.targets.repositories import DjangoOrganizationRepository
|
||||
def create_with_configuration(self, dto: ScheduledScanDTO) -> ScheduledScan:
|
||||
"""
|
||||
创建定时扫描任务(直接使用前端传递的配置)
|
||||
|
||||
if not dto.name:
|
||||
raise ValidationError('任务名称不能为空')
|
||||
流程:
|
||||
1. 验证参数
|
||||
2. 直接使用 dto.yaml_configuration
|
||||
3. 创建数据库记录
|
||||
4. 计算并设置 next_run_time
|
||||
|
||||
Args:
|
||||
dto: 定时扫描 DTO(必须包含 yaml_configuration)
|
||||
|
||||
Returns:
|
||||
创建的 ScheduledScan 对象
|
||||
|
||||
Raises:
|
||||
ValidationError: 参数验证失败
|
||||
"""
|
||||
# 1. 验证参数
|
||||
self._validate_create_dto_with_configuration(dto)
|
||||
|
||||
# 2. 创建数据库记录(直接使用 dto 中的配置)
|
||||
scheduled_scan = self.repo.create(dto)
|
||||
|
||||
# 3. 如果有 cron 表达式且已启用,计算下次执行时间
|
||||
if scheduled_scan.cron_expression and scheduled_scan.is_enabled:
|
||||
next_run_time = self._calculate_next_run_time(scheduled_scan)
|
||||
if next_run_time:
|
||||
self.repo.update_next_run_time(scheduled_scan.id, next_run_time)
|
||||
scheduled_scan.next_run_time = next_run_time
|
||||
|
||||
logger.info(
|
||||
"创建定时扫描任务 - ID: %s, 名称: %s, 下次执行: %s",
|
||||
scheduled_scan.id, scheduled_scan.name, scheduled_scan.next_run_time
|
||||
)
|
||||
|
||||
return scheduled_scan
|
||||
|
||||
def _validate_create_dto(self, dto: ScheduledScanDTO) -> None:
|
||||
"""验证创建 DTO(使用引擎 ID)"""
|
||||
# 基础验证
|
||||
self._validate_base_dto(dto)
|
||||
|
||||
if not dto.engine_ids:
|
||||
raise ValidationError('必须选择扫描引擎')
|
||||
@@ -121,6 +158,21 @@ class ScheduledScanService:
|
||||
for engine_id in dto.engine_ids:
|
||||
if not self.engine_repo.get_by_id(engine_id):
|
||||
raise ValidationError(f'扫描引擎 ID {engine_id} 不存在')
|
||||
|
||||
def _validate_create_dto_with_configuration(self, dto: ScheduledScanDTO) -> None:
|
||||
"""验证创建 DTO(使用前端传递的配置)"""
|
||||
# 基础验证
|
||||
self._validate_base_dto(dto)
|
||||
|
||||
if not dto.yaml_configuration:
|
||||
raise ValidationError('配置不能为空')
|
||||
|
||||
def _validate_base_dto(self, dto: ScheduledScanDTO) -> None:
|
||||
"""验证 DTO 的基础字段(公共逻辑)"""
|
||||
from apps.targets.repositories import DjangoOrganizationRepository
|
||||
|
||||
if not dto.name:
|
||||
raise ValidationError('任务名称不能为空')
|
||||
|
||||
# 验证扫描模式(organization_id 和 target_id 互斥)
|
||||
if not dto.organization_id and not dto.target_id:
|
||||
@@ -178,7 +230,7 @@ class ScheduledScanService:
|
||||
|
||||
merged_configuration = merge_engine_configs(engines)
|
||||
dto.engine_names = engine_names
|
||||
dto.merged_configuration = merged_configuration
|
||||
dto.yaml_configuration = merged_configuration
|
||||
|
||||
# 更新数据库记录
|
||||
scheduled_scan = self.repo.update(scheduled_scan_id, dto)
|
||||
@@ -329,7 +381,7 @@ class ScheduledScanService:
|
||||
立即触发扫描(支持组织扫描和目标扫描两种模式)
|
||||
|
||||
复用 ScanService 的逻辑,与 API 调用保持一致。
|
||||
使用存储的 merged_configuration 而不是重新合并。
|
||||
使用存储的 yaml_configuration 而不是重新合并。
|
||||
"""
|
||||
from apps.scan.services.scan_service import ScanService
|
||||
|
||||
@@ -347,7 +399,7 @@ class ScheduledScanService:
|
||||
targets=targets,
|
||||
engine_ids=scheduled_scan.engine_ids,
|
||||
engine_names=scheduled_scan.engine_names,
|
||||
merged_configuration=scheduled_scan.merged_configuration,
|
||||
yaml_configuration=scheduled_scan.yaml_configuration,
|
||||
scheduled_scan_name=scheduled_scan.name
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
from ..models import Scan, ScheduledScan
|
||||
from ..serializers import (
|
||||
ScanSerializer, ScanHistorySerializer, QuickScanSerializer,
|
||||
ScheduledScanSerializer, CreateScheduledScanSerializer,
|
||||
InitiateScanSerializer, ScheduledScanSerializer, CreateScheduledScanSerializer,
|
||||
UpdateScheduledScanSerializer, ToggleScheduledScanSerializer
|
||||
)
|
||||
from ..services.scan_service import ScanService
|
||||
@@ -111,7 +111,7 @@ class ScanViewSet(viewsets.ModelViewSet):
|
||||
快速扫描接口
|
||||
|
||||
功能:
|
||||
1. 接收目标列表和引擎配置
|
||||
1. 接收目标列表和 YAML 配置
|
||||
2. 自动解析输入(支持 URL、域名、IP、CIDR)
|
||||
3. 批量创建 Target、Website、Endpoint 资产
|
||||
4. 立即发起批量扫描
|
||||
@@ -119,7 +119,9 @@ class ScanViewSet(viewsets.ModelViewSet):
|
||||
请求参数:
|
||||
{
|
||||
"targets": [{"name": "example.com"}, {"name": "https://example.com/api"}],
|
||||
"engine_ids": [1, 2]
|
||||
"configuration": "subdomain_discovery:\n enabled: true\n ...",
|
||||
"engine_ids": [1, 2], // 可选,用于记录
|
||||
"engine_names": ["引擎A", "引擎B"] // 可选,用于记录
|
||||
}
|
||||
|
||||
支持的输入格式:
|
||||
@@ -134,7 +136,9 @@ class ScanViewSet(viewsets.ModelViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
targets_data = serializer.validated_data['targets']
|
||||
engine_ids = serializer.validated_data.get('engine_ids')
|
||||
configuration = serializer.validated_data['configuration']
|
||||
engine_ids = serializer.validated_data.get('engine_ids', [])
|
||||
engine_names = serializer.validated_data.get('engine_names', [])
|
||||
|
||||
try:
|
||||
# 提取输入字符串列表
|
||||
@@ -154,19 +158,13 @@ class ScanViewSet(viewsets.ModelViewSet):
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 2. 准备多引擎扫描
|
||||
# 2. 直接使用前端传递的配置创建扫描
|
||||
scan_service = ScanService()
|
||||
_, merged_configuration, engine_names, engine_ids = scan_service.prepare_initiate_scan_multi_engine(
|
||||
target_id=targets[0].id, # 使用第一个目标来验证引擎
|
||||
engine_ids=engine_ids
|
||||
)
|
||||
|
||||
# 3. 批量发起扫描
|
||||
created_scans = scan_service.create_scans(
|
||||
targets=targets,
|
||||
engine_ids=engine_ids,
|
||||
engine_names=engine_names,
|
||||
merged_configuration=merged_configuration
|
||||
yaml_configuration=configuration
|
||||
)
|
||||
|
||||
# 检查是否成功创建扫描任务
|
||||
@@ -195,17 +193,6 @@ class ScanViewSet(viewsets.ModelViewSet):
|
||||
},
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
except ConfigConflictError as e:
|
||||
return error_response(
|
||||
code='CONFIG_CONFLICT',
|
||||
message=str(e),
|
||||
details=[
|
||||
{'key': k, 'engines': [e1, e2]}
|
||||
for k, e1, e2 in e.conflicts
|
||||
],
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return error_response(
|
||||
@@ -228,48 +215,53 @@ class ScanViewSet(viewsets.ModelViewSet):
|
||||
请求参数:
|
||||
- organization_id: 组织ID (int, 可选)
|
||||
- target_id: 目标ID (int, 可选)
|
||||
- configuration: YAML 配置字符串 (str, 必填)
|
||||
- engine_ids: 扫描引擎ID列表 (list[int], 必填)
|
||||
- engine_names: 引擎名称列表 (list[str], 必填)
|
||||
|
||||
注意: organization_id 和 target_id 二选一
|
||||
|
||||
返回:
|
||||
- 扫描任务详情(单个或多个)
|
||||
"""
|
||||
# 获取请求数据
|
||||
organization_id = request.data.get('organization_id')
|
||||
target_id = request.data.get('target_id')
|
||||
engine_ids = request.data.get('engine_ids')
|
||||
# 使用 serializer 验证请求数据
|
||||
serializer = InitiateScanSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# 验证 engine_ids
|
||||
if not engine_ids:
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message='缺少必填参数: engine_ids',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not isinstance(engine_ids, list):
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message='engine_ids 必须是数组',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
# 获取验证后的数据
|
||||
organization_id = serializer.validated_data.get('organization_id')
|
||||
target_id = serializer.validated_data.get('target_id')
|
||||
configuration = serializer.validated_data['configuration']
|
||||
engine_ids = serializer.validated_data['engine_ids']
|
||||
engine_names = serializer.validated_data['engine_names']
|
||||
|
||||
try:
|
||||
# 步骤1:准备多引擎扫描所需的数据
|
||||
# 获取目标列表
|
||||
scan_service = ScanService()
|
||||
targets, merged_configuration, engine_names, engine_ids = scan_service.prepare_initiate_scan_multi_engine(
|
||||
organization_id=organization_id,
|
||||
target_id=target_id,
|
||||
engine_ids=engine_ids
|
||||
)
|
||||
|
||||
# 步骤2:批量创建扫描记录并分发扫描任务
|
||||
if organization_id:
|
||||
from apps.targets.repositories import DjangoOrganizationRepository
|
||||
org_repo = DjangoOrganizationRepository()
|
||||
organization = org_repo.get_by_id(organization_id)
|
||||
if not organization:
|
||||
raise ObjectDoesNotExist(f'Organization ID {organization_id} 不存在')
|
||||
targets = org_repo.get_targets(organization_id)
|
||||
if not targets:
|
||||
raise ValidationError(f'组织 ID {organization_id} 下没有目标')
|
||||
else:
|
||||
from apps.targets.repositories import DjangoTargetRepository
|
||||
target_repo = DjangoTargetRepository()
|
||||
target = target_repo.get_by_id(target_id)
|
||||
if not target:
|
||||
raise ObjectDoesNotExist(f'Target ID {target_id} 不存在')
|
||||
targets = [target]
|
||||
|
||||
# 直接使用前端传递的配置创建扫描
|
||||
created_scans = scan_service.create_scans(
|
||||
targets=targets,
|
||||
engine_ids=engine_ids,
|
||||
engine_names=engine_names,
|
||||
merged_configuration=merged_configuration
|
||||
yaml_configuration=configuration
|
||||
)
|
||||
|
||||
# 检查是否成功创建扫描任务
|
||||
@@ -290,17 +282,6 @@ class ScanViewSet(viewsets.ModelViewSet):
|
||||
},
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
except ConfigConflictError as e:
|
||||
return error_response(
|
||||
code='CONFIG_CONFLICT',
|
||||
message=str(e),
|
||||
details=[
|
||||
{'key': k, 'engines': [e1, e2]}
|
||||
for k, e1, e2 in e.conflicts
|
||||
],
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except ObjectDoesNotExist as e:
|
||||
# 资源不存在错误(由 service 层抛出)
|
||||
|
||||
@@ -68,30 +68,22 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
|
||||
data = serializer.validated_data
|
||||
dto = ScheduledScanDTO(
|
||||
name=data['name'],
|
||||
engine_ids=data['engine_ids'],
|
||||
engine_ids=data.get('engine_ids', []),
|
||||
engine_names=data.get('engine_names', []),
|
||||
yaml_configuration=data['configuration'],
|
||||
organization_id=data.get('organization_id'),
|
||||
target_id=data.get('target_id'),
|
||||
cron_expression=data.get('cron_expression', '0 2 * * *'),
|
||||
is_enabled=data.get('is_enabled', True),
|
||||
)
|
||||
|
||||
scheduled_scan = self.service.create(dto)
|
||||
scheduled_scan = self.service.create_with_configuration(dto)
|
||||
response_serializer = ScheduledScanSerializer(scheduled_scan)
|
||||
|
||||
return success_response(
|
||||
data=response_serializer.data,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
except ConfigConflictError as e:
|
||||
return error_response(
|
||||
code='CONFIG_CONFLICT',
|
||||
message=str(e),
|
||||
details=[
|
||||
{'key': k, 'engines': [e1, e2]}
|
||||
for k, e1, e2 in e.conflicts
|
||||
],
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except ValidationError as e:
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
|
||||
@@ -219,6 +219,8 @@ REST_FRAMEWORK = {
|
||||
# 允许所有来源(前后端分离项目,安全性由认证系统保障)
|
||||
CORS_ALLOW_ALL_ORIGINS = os.getenv('CORS_ALLOW_ALL_ORIGINS', 'True').lower() == 'true'
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
# 暴露额外的响应头给前端(Content-Disposition 用于文件下载获取文件名)
|
||||
CORS_EXPOSE_HEADERS = ['Content-Disposition']
|
||||
|
||||
# ==================== CSRF 配置 ====================
|
||||
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://127.0.0.1:3000').split(',')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# 第一阶段:使用 Go 官方镜像编译工具
|
||||
# 锁定 digest 避免上游更新导致缓存失效
|
||||
FROM golang:1.24@sha256:7e050c14ae9ca5ae56408a288336545b18632f51402ab0ec8e7be0e649a1fc42 AS go-builder
|
||||
FROM golang:1.24 AS go-builder
|
||||
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
# Naabu 需要 CGO 和 libpcap
|
||||
@@ -37,8 +36,7 @@ RUN CGO_ENABLED=0 go install -v github.com/owasp-amass/amass/v5/cmd/amass@main
|
||||
RUN go install github.com/hahwul/dalfox/v2@latest
|
||||
|
||||
# 第二阶段:运行时镜像
|
||||
# 锁定 digest 避免上游更新导致缓存失效
|
||||
FROM ubuntu:24.04@sha256:4fdf0125919d24aec972544669dcd7d6a26a8ad7e6561c73d5549bd6db258ac2
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# 避免交互式提示
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
318
frontend/components/scan/engine-preset-selector.tsx
Normal file
318
frontend/components/scan/engine-preset-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Play, Settings2 } from "lucide-react"
|
||||
import React, { useState, useMemo, useCallback } from "react"
|
||||
import { Play, Server, Settings, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -9,15 +9,22 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { LoadingSpinner } from "@/components/loading-spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities } from "@/lib/engine-config"
|
||||
import { EnginePresetSelector } from "./engine-preset-selector"
|
||||
import { ScanConfigEditor } from "./scan-config-editor"
|
||||
|
||||
import type { Organization } from "@/types/organization.types"
|
||||
|
||||
@@ -46,36 +53,78 @@ 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 [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])
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedEngineIds((prev) => [...prev, engineId])
|
||||
// Handle configuration change from preset selector (may need confirmation)
|
||||
const handlePresetConfigChange = useCallback((value: string) => {
|
||||
if (isConfigEdited && configuration !== value) {
|
||||
setPendingConfigChange(value)
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
setSelectedEngineIds((prev) => prev.filter((id) => id !== engineId))
|
||||
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 (pendingConfigChange !== null) {
|
||||
setConfiguration(pendingConfigChange)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleYamlValidationChange = (isValid: boolean) => {
|
||||
setIsYamlValid(isValid)
|
||||
}
|
||||
|
||||
const handleInitiate = async () => {
|
||||
if (!selectedEngineIds.length) return
|
||||
if (selectedEngineIds.length === 0) {
|
||||
toast.error(tToast("noEngineSelected"))
|
||||
return
|
||||
}
|
||||
if (!configuration.trim()) {
|
||||
toast.error(tToast("emptyConfig"))
|
||||
return
|
||||
}
|
||||
if (!organizationId && !targetId) {
|
||||
toast.error(tToast("paramError"), { description: tToast("paramErrorDesc") })
|
||||
return
|
||||
@@ -85,7 +134,9 @@ export function InitiateScanDialog({
|
||||
const response = await initiateScan({
|
||||
organizationId,
|
||||
targetId,
|
||||
configuration,
|
||||
engineIds: selectedEngineIds,
|
||||
engineNames: selectedEngines.map(e => e.name),
|
||||
})
|
||||
|
||||
// 后端返回 201 说明成功创建扫描任务
|
||||
@@ -96,19 +147,14 @@ export function InitiateScanDialog({
|
||||
onSuccess?.()
|
||||
onOpenChange(false)
|
||||
setSelectedEngineIds([])
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to initiate scan:", err)
|
||||
// 处理配置冲突错误
|
||||
const error = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
|
||||
if (error?.response?.data?.error?.code === 'CONFIG_CONFLICT') {
|
||||
toast.error(tToast("configConflict"), {
|
||||
description: error.response.data.error.message,
|
||||
})
|
||||
} else {
|
||||
toast.error(tToast("initiateScanFailed"), {
|
||||
description: err instanceof Error ? err.message : tToast("unknownError"),
|
||||
})
|
||||
}
|
||||
toast.error(tToast("initiateScanFailed"), {
|
||||
description: error?.response?.data?.error?.message || (err instanceof Error ? err.message : tToast("unknownError")),
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -117,158 +163,127 @@ export function InitiateScanDialog({
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!isSubmitting) {
|
||||
onOpenChange(newOpen)
|
||||
if (!newOpen) setSelectedEngineIds([])
|
||||
if (!newOpen) {
|
||||
setStep(1)
|
||||
setSelectedPresetId(null)
|
||||
setSelectedEngineIds([])
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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>
|
||||
<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">
|
||||
<pre className="h-full p-3 text-xs font-mono overflow-auto whitespace-pre-wrap break-all">
|
||||
{selectedEngines.map((e) => e.configuration || `# ${t("noConfig")}`).join("\n\n")}
|
||||
</pre>
|
||||
</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 items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Settings2 className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("selectEngineHint")}</p>
|
||||
</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 || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
{t("initiating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t("startScan")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* Overwrite confirmation dialog */}
|
||||
<AlertDialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("overwriteConfirm.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("overwriteConfirm.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleOverwriteCancel}>
|
||||
{t("overwriteConfirm.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleOverwriteConfirm}>
|
||||
{t("overwriteConfirm.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,18 +11,27 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} 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 { 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 } 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
|
||||
@@ -36,8 +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)
|
||||
|
||||
const { data: engines, isLoading, error } = useEngines()
|
||||
// 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 [pendingConfigChange, setPendingConfigChange] = React.useState<string | null>(null)
|
||||
|
||||
const { data: engines } = useEngines()
|
||||
|
||||
const lineNumbersRef = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -61,18 +78,12 @@ 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])
|
||||
|
||||
const resetForm = () => {
|
||||
setTargetInput("")
|
||||
setSelectedEngineIds([])
|
||||
setSelectedPresetId(null)
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
@@ -81,19 +92,52 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
if (!isOpen) resetForm()
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedEngineIds((prev) => [...prev, engineId])
|
||||
// Handle configuration change from preset selector (may need confirmation)
|
||||
const handlePresetConfigChange = React.useCallback((value: string) => {
|
||||
if (isConfigEdited && configuration !== value) {
|
||||
setPendingConfigChange(value)
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
setSelectedEngineIds((prev) => prev.filter((id) => id !== engineId))
|
||||
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 (pendingConfigChange !== null) {
|
||||
setConfiguration(pendingConfigChange)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleYamlValidationChange = (isValid: boolean) => {
|
||||
setIsYamlValid(isValid)
|
||||
}
|
||||
|
||||
const canProceedToStep2 = validInputs.length > 0 && !hasErrors
|
||||
const canSubmit = selectedEngineIds.length > 0
|
||||
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 = () => {
|
||||
@@ -103,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 () => {
|
||||
@@ -118,6 +163,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
toast.error(t("toast.selectEngine"))
|
||||
return
|
||||
}
|
||||
if (!configuration.trim()) {
|
||||
toast.error(t("toast.emptyConfig"))
|
||||
return
|
||||
}
|
||||
|
||||
const targets = validInputs.map(r => r.originalInput)
|
||||
|
||||
@@ -125,7 +174,9 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
try {
|
||||
const response = await quickScan({
|
||||
targets: targets.map(name => ({ name })),
|
||||
configuration,
|
||||
engineIds: selectedEngineIds,
|
||||
engineNames: selectedEngines.map(e => e.name),
|
||||
})
|
||||
|
||||
const { targetStats, scans, count } = response
|
||||
@@ -139,13 +190,7 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
handleClose(false)
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: { code?: string; message?: string }; detail?: string } } }
|
||||
if (err?.response?.data?.error?.code === 'CONFIG_CONFLICT') {
|
||||
toast.error(t("toast.configConflict"), {
|
||||
description: err.response.data.error.message,
|
||||
})
|
||||
} else {
|
||||
toast.error(err?.response?.data?.detail || err?.response?.data?.error?.message || t("toast.createFailed"))
|
||||
}
|
||||
toast.error(err?.response?.data?.detail || err?.response?.data?.error?.message || t("toast.createFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -179,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>
|
||||
@@ -259,118 +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>
|
||||
</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">
|
||||
<pre className="h-full p-3 text-xs font-mono overflow-auto whitespace-pre-wrap break-all">
|
||||
{selectedEngines.map((e) => e.configuration || `# ${t("noConfig")}`).join("\n\n")}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Settings2 className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("selectEngineHint")}</p>
|
||||
</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">
|
||||
@@ -392,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" />
|
||||
@@ -418,6 +347,26 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* Overwrite confirmation dialog */}
|
||||
<AlertDialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("overwriteConfirm.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("overwriteConfirm.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleOverwriteCancel}>
|
||||
{t("overwriteConfirm.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleOverwriteConfirm}>
|
||||
{t("overwriteConfirm.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
86
frontend/components/scan/scan-config-editor.tsx
Normal file
86
frontend/components/scan/scan-config-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export function ScanProgressDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[500px] sm:min-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ScanStatusIcon status={data.status} />
|
||||
@@ -209,9 +209,19 @@ export function ScanProgressDialog({
|
||||
<span className="text-muted-foreground">{t("target")}</span>
|
||||
<span className="font-medium">{data.targetName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("engine")}</span>
|
||||
<Badge variant="secondary">{data.engineNames?.join(", ") || "-"}</Badge>
|
||||
<div className="flex items-start justify-between text-sm gap-4">
|
||||
<span className="text-muted-foreground shrink-0">{t("engine")}</span>
|
||||
<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">
|
||||
{name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{data.startedAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -34,6 +44,8 @@ import {
|
||||
IconClock,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconCode,
|
||||
} from "@tabler/icons-react"
|
||||
import { CronExpressionParser } from "cron-parser"
|
||||
import cronstrue from "cronstrue/i18n"
|
||||
@@ -44,9 +56,10 @@ import { useEngines } from "@/hooks/use-engines"
|
||||
import { useOrganizations } from "@/hooks/use-organizations"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
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 {
|
||||
@@ -85,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("")
|
||||
@@ -120,10 +135,18 @@ 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)
|
||||
const [cronExpression, setCronExpression] = React.useState("0 2 * * *")
|
||||
|
||||
// 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 [pendingConfigChange, setPendingConfigChange] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -140,25 +163,65 @@ 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
|
||||
const selectedEngines = React.useMemo(() => {
|
||||
if (!engineIds.length || !engines.length) return []
|
||||
return engines.filter(e => engineIds.includes(e.id))
|
||||
}, [engineIds, engines])
|
||||
|
||||
const resetForm = () => {
|
||||
setName("")
|
||||
setEngineIds([])
|
||||
setSelectedPresetId(null)
|
||||
setSelectionMode("organization")
|
||||
setSelectedOrgId(null)
|
||||
setSelectedTargetId(null)
|
||||
setCronExpression("0 2 * * *")
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
resetStep()
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setEngineIds((prev) => [...prev, engineId])
|
||||
// Handle configuration change from preset selector (may need confirmation)
|
||||
const handlePresetConfigChange = React.useCallback((value: string) => {
|
||||
if (isConfigEdited && configuration !== value) {
|
||||
setPendingConfigChange(value)
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
setEngineIds((prev) => prev.filter((id) => id !== engineId))
|
||||
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 (pendingConfigChange !== null) {
|
||||
setConfiguration(pendingConfigChange)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleYamlValidationChange = (isValid: boolean) => {
|
||||
setIsYamlValid(isValid)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
@@ -177,11 +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:
|
||||
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 3: // Schedule
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5) { toast.error(t("form.cronRequired")); return false }
|
||||
return true
|
||||
@@ -190,19 +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 }
|
||||
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
|
||||
@@ -216,7 +289,9 @@ export function CreateScheduledScanDialog({
|
||||
if (!validateCurrentStep()) return
|
||||
const request: CreateScheduledScanRequest = {
|
||||
name: name.trim(),
|
||||
configuration: configuration.trim(),
|
||||
engineIds: engineIds,
|
||||
engineNames: selectedEngines.map(e => e.name),
|
||||
cronExpression: cronExpression.trim(),
|
||||
}
|
||||
if (selectionMode === "organization" && selectedOrgId) {
|
||||
@@ -262,82 +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-[200px] 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>
|
||||
)}
|
||||
|
||||
{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">
|
||||
@@ -364,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>
|
||||
@@ -451,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" />
|
||||
@@ -489,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>
|
||||
@@ -504,6 +552,26 @@ export function CreateScheduledScanDialog({
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Overwrite confirmation dialog */}
|
||||
<AlertDialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("overwriteConfirm.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("overwriteConfirm.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleOverwriteCancel}>
|
||||
{t("overwriteConfirm.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleOverwriteConfirm}>
|
||||
{t("overwriteConfirm.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
194
frontend/components/ui/yaml-editor.tsx
Normal file
194
frontend/components/ui/yaml-editor.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react"
|
||||
import Editor from "@monaco-editor/react"
|
||||
import * as yaml from "js-yaml"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useColorTheme } from "@/hooks/use-color-theme"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface YamlEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
height?: string
|
||||
className?: string
|
||||
onValidationChange?: (isValid: boolean, error?: { message: string; line?: number; column?: number }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML Editor component with Monaco Editor
|
||||
* Provides VSCode-level editing experience with syntax highlighting and validation
|
||||
*/
|
||||
export function YamlEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
height = "100%",
|
||||
className,
|
||||
onValidationChange,
|
||||
}: YamlEditorProps) {
|
||||
const t = useTranslations("common.yamlEditor")
|
||||
const { currentTheme } = useColorTheme()
|
||||
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()) {
|
||||
setYamlError(null)
|
||||
onValidationChange?.(true)
|
||||
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)
|
||||
onValidationChange?.(true)
|
||||
return true
|
||||
} catch (error) {
|
||||
const yamlException = error as yaml.YAMLException
|
||||
const errorInfo = {
|
||||
message: yamlException.message,
|
||||
line: yamlException.mark?.line ? yamlException.mark.line + 1 : undefined,
|
||||
column: yamlException.mark?.column ? yamlException.mark.column + 1 : undefined,
|
||||
}
|
||||
setYamlError(errorInfo)
|
||||
onValidationChange?.(false, errorInfo)
|
||||
return false
|
||||
}
|
||||
}, [onValidationChange, checkDuplicateKeys, t])
|
||||
|
||||
// Handle editor content change
|
||||
const handleEditorChange = useCallback((newValue: string | undefined) => {
|
||||
const content = newValue || ""
|
||||
onChange(content)
|
||||
validateYaml(content)
|
||||
}, [onChange, validateYaml])
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorDidMount = useCallback(() => {
|
||||
// Validate initial content
|
||||
validateYaml(value)
|
||||
}, [validateYaml, value])
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Monaco Editor */}
|
||||
<div className={cn("flex-1 overflow-hidden", yamlError ? 'border-destructive' : '')}>
|
||||
{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 */}
|
||||
{yamlError && (
|
||||
<div className="flex items-start gap-2 p-2 bg-destructive/10 border-t border-destructive/20">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 text-xs">
|
||||
<p className="font-medium text-destructive">
|
||||
{yamlError.line && yamlError.column
|
||||
? t("errorLocation", { line: yamlError.line, column: yamlError.column })
|
||||
: t("syntaxError")}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate">{yamlError.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -80,3 +80,14 @@ export function parseEngineCapabilities(configuration: string): string[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple engine configurations into a single YAML string
|
||||
* Simply concatenates configurations with separators
|
||||
*/
|
||||
export function mergeEngineConfigurations(configurations: string[]): string {
|
||||
const validConfigs = configurations.filter(c => c && c.trim())
|
||||
if (validConfigs.length === 0) return ""
|
||||
if (validConfigs.length === 1) return validConfigs[0]
|
||||
return validConfigs.join("\n\n# ---\n\n")
|
||||
}
|
||||
|
||||
@@ -175,6 +175,13 @@
|
||||
"website": "Website",
|
||||
"description": "Description"
|
||||
},
|
||||
"yamlEditor": {
|
||||
"syntaxError": "Syntax Error",
|
||||
"syntaxValid": "Syntax Valid",
|
||||
"errorLocation": "Line {line}, Column {column}",
|
||||
"loading": "Loading editor...",
|
||||
"duplicateKey": "Duplicate key '{key}' found. Later values will override earlier ones. Please remove duplicates."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Switch to light mode",
|
||||
"switchToDark": "Switch to dark mode",
|
||||
@@ -654,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",
|
||||
@@ -736,10 +776,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": {
|
||||
@@ -749,8 +792,14 @@
|
||||
"taskNameRequired": "Please enter task name",
|
||||
"scanEngine": "Scan Engine",
|
||||
"scanEnginePlaceholder": "Select scan engine",
|
||||
"scanEngineDesc": "Select the scan engine configuration to use",
|
||||
"scanEngineDesc": "Select engine to auto-fill configuration, or edit directly",
|
||||
"scanEngineRequired": "Please select a scan engine",
|
||||
"configuration": "Scan Configuration",
|
||||
"configurationPlaceholder": "Enter YAML scan configuration...",
|
||||
"configurationDesc": "YAML format scan configuration, select engine to auto-fill or edit manually",
|
||||
"configurationRequired": "Please enter scan configuration",
|
||||
"yamlInvalid": "Invalid YAML configuration, please check syntax",
|
||||
"configEdited": "Edited",
|
||||
"selectScanMode": "Select Scan Mode",
|
||||
"organizationScan": "Organization Scan",
|
||||
"organizationScanDesc": "Select organization, dynamically fetch all targets at execution",
|
||||
@@ -782,6 +831,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"
|
||||
},
|
||||
@@ -803,7 +854,14 @@
|
||||
},
|
||||
"toast": {
|
||||
"selectOrganization": "Please select an organization",
|
||||
"selectTarget": "Please select a scan target"
|
||||
"selectTarget": "Please select a scan target",
|
||||
"configConflict": "Configuration conflict"
|
||||
},
|
||||
"overwriteConfirm": {
|
||||
"title": "Overwrite Configuration",
|
||||
"description": "You have manually edited the configuration. Changing engines will overwrite your changes. Do you want to continue?",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Overwrite"
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
@@ -1405,6 +1463,8 @@
|
||||
"initiateScanFailed": "Failed to initiate scan",
|
||||
"noScansCreated": "No scan tasks were created",
|
||||
"unknownError": "Unknown error",
|
||||
"noEngineSelected": "Please select at least one scan engine",
|
||||
"emptyConfig": "Scan configuration cannot be empty",
|
||||
"engineNameRequired": "Please enter engine name",
|
||||
"configRequired": "Configuration content is required",
|
||||
"yamlSyntaxError": "YAML syntax error",
|
||||
@@ -1709,7 +1769,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",
|
||||
@@ -1737,10 +1798,19 @@
|
||||
"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",
|
||||
"selectEngine": "Please select a scan engine",
|
||||
"emptyConfig": "Scan configuration cannot be empty",
|
||||
"getEnginesFailed": "Failed to get engine list",
|
||||
"createFailed": "Failed to create scan task",
|
||||
"createSuccess": "Created {count} scan tasks",
|
||||
|
||||
@@ -175,6 +175,13 @@
|
||||
"website": "官网",
|
||||
"description": "描述"
|
||||
},
|
||||
"yamlEditor": {
|
||||
"syntaxError": "语法错误",
|
||||
"syntaxValid": "语法正确",
|
||||
"errorLocation": "第 {line} 行,第 {column} 列",
|
||||
"loading": "加载编辑器...",
|
||||
"duplicateKey": "发现重复的配置项 '{key}',后面的配置会覆盖前面的,请删除重复项"
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "切换到亮色模式",
|
||||
"switchToDark": "切换到暗色模式",
|
||||
@@ -654,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": "每分钟",
|
||||
@@ -736,10 +776,13 @@
|
||||
"createDesc": "配置定时扫描任务,设置执行计划",
|
||||
"editTitle": "编辑定时扫描",
|
||||
"editDesc": "修改定时扫描任务配置",
|
||||
"stepIndicator": "步骤 {current}/{total}",
|
||||
"steps": {
|
||||
"basicInfo": "基本信息",
|
||||
"scanMode": "扫描模式",
|
||||
"selectTarget": "选择目标",
|
||||
"selectEngine": "选择引擎",
|
||||
"editConfig": "编辑配置",
|
||||
"scheduleSettings": "调度设置"
|
||||
},
|
||||
"form": {
|
||||
@@ -749,8 +792,14 @@
|
||||
"taskNameRequired": "请输入任务名称",
|
||||
"scanEngine": "扫描引擎",
|
||||
"scanEnginePlaceholder": "选择扫描引擎",
|
||||
"scanEngineDesc": "选择要使用的扫描引擎配置",
|
||||
"scanEngineDesc": "选择引擎可快速填充配置,也可直接编辑配置",
|
||||
"scanEngineRequired": "请选择扫描引擎",
|
||||
"configuration": "扫描配置",
|
||||
"configurationPlaceholder": "请输入 YAML 格式的扫描配置...",
|
||||
"configurationDesc": "YAML 格式的扫描配置,可选择引擎自动填充或手动编辑",
|
||||
"configurationRequired": "请输入扫描配置",
|
||||
"yamlInvalid": "YAML 配置格式错误,请检查语法",
|
||||
"configEdited": "已编辑",
|
||||
"selectScanMode": "选择扫描模式",
|
||||
"organizationScan": "组织扫描",
|
||||
"organizationScanDesc": "选择组织,执行时动态获取其下所有目标",
|
||||
@@ -782,6 +831,8 @@
|
||||
"organizationModeHint": "组织扫描模式下,执行时将动态获取该组织下所有目标",
|
||||
"noAvailableTarget": "暂无可用目标",
|
||||
"noEngine": "暂无可用引擎",
|
||||
"noConfig": "无配置",
|
||||
"capabilitiesCount": "{count} 项能力",
|
||||
"selected": "已选择",
|
||||
"selectedEngines": "已选择 {count} 个引擎"
|
||||
},
|
||||
@@ -803,7 +854,14 @@
|
||||
},
|
||||
"toast": {
|
||||
"selectOrganization": "请选择一个组织",
|
||||
"selectTarget": "请选择一个扫描目标"
|
||||
"selectTarget": "请选择一个扫描目标",
|
||||
"configConflict": "配置冲突"
|
||||
},
|
||||
"overwriteConfirm": {
|
||||
"title": "覆盖配置确认",
|
||||
"description": "您已手动编辑了配置,切换引擎将覆盖当前配置。确定要继续吗?",
|
||||
"cancel": "取消",
|
||||
"confirm": "确定覆盖"
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
@@ -1405,6 +1463,8 @@
|
||||
"initiateScanFailed": "发起扫描失败",
|
||||
"noScansCreated": "未创建任何扫描任务",
|
||||
"unknownError": "未知错误",
|
||||
"noEngineSelected": "请选择至少一个扫描引擎",
|
||||
"emptyConfig": "扫描配置不能为空",
|
||||
"engineNameRequired": "请输入引擎名称",
|
||||
"configRequired": "配置内容不能为空",
|
||||
"yamlSyntaxError": "YAML 语法错误",
|
||||
@@ -1709,7 +1769,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",
|
||||
@@ -1737,10 +1798,19 @@
|
||||
"andMore": "还有 {count} 个...",
|
||||
"selectedEngines": "已选引擎",
|
||||
"confirmSummary": "将使用 {engineCount} 个引擎扫描 {targetCount} 个目标",
|
||||
"configTitle": "扫描配置",
|
||||
"configEdited": "已编辑",
|
||||
"overwriteConfirm": {
|
||||
"title": "覆盖配置确认",
|
||||
"description": "您已手动编辑过配置,切换引擎将覆盖当前配置。是否继续?",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认覆盖"
|
||||
},
|
||||
"toast": {
|
||||
"noValidTarget": "请输入至少一个有效目标",
|
||||
"hasInvalidInputs": "存在 {count} 个无效输入,请修正后继续",
|
||||
"selectEngine": "请选择扫描引擎",
|
||||
"emptyConfig": "扫描配置不能为空",
|
||||
"getEnginesFailed": "获取引擎列表失败",
|
||||
"createFailed": "创建扫描任务失败",
|
||||
"createSuccess": "已创建 {count} 个扫描任务",
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,9 @@ export interface GetScansResponse {
|
||||
export interface InitiateScanRequest {
|
||||
organizationId?: number // Organization ID (choose one)
|
||||
targetId?: number // Target ID (choose one)
|
||||
configuration: string // YAML configuration string (required)
|
||||
engineIds: number[] // Scan engine ID list (required)
|
||||
engineNames: string[] // Engine name list (required)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +92,9 @@ export interface InitiateScanRequest {
|
||||
*/
|
||||
export interface QuickScanRequest {
|
||||
targets: { name: string }[] // Target list
|
||||
configuration: string // YAML configuration string (required)
|
||||
engineIds: number[] // Scan engine ID list (required)
|
||||
engineNames: string[] // Engine name list (required)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,9 @@ export interface ScheduledScan {
|
||||
// Create scheduled scan request (organizationId and targetId are mutually exclusive)
|
||||
export interface CreateScheduledScanRequest {
|
||||
name: string
|
||||
engineIds: number[] // Engine ID list
|
||||
configuration: string // YAML configuration string (required)
|
||||
engineIds: number[] // Engine ID list (required)
|
||||
engineNames: string[] // Engine name list (required)
|
||||
organizationId?: number // Organization scan mode
|
||||
targetId?: number // Target scan mode
|
||||
cronExpression: string // Cron expression, format: minute hour day month weekday
|
||||
@@ -41,7 +43,9 @@ export interface CreateScheduledScanRequest {
|
||||
// Update scheduled scan request (organizationId and targetId are mutually exclusive)
|
||||
export interface UpdateScheduledScanRequest {
|
||||
name?: string
|
||||
engineIds?: number[] // Engine ID list
|
||||
configuration?: string // YAML configuration string
|
||||
engineIds?: number[] // Engine ID list (optional, for reference)
|
||||
engineNames?: string[] // Engine name list (optional, for reference)
|
||||
organizationId?: number // Organization scan mode (clears targetId when set)
|
||||
targetId?: number // Target scan mode (clears organizationId when set)
|
||||
cronExpression?: string
|
||||
|
||||
Reference in New Issue
Block a user