mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 11:46:16 +08:00
Compare commits
8 Commits
v1.3.7-dev
...
v1.3.11-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a8062a12d | ||
|
|
55908a2da5 | ||
|
|
22a7d4f091 | ||
|
|
f287f18134 | ||
|
|
de27230b7a | ||
|
|
15a6295189 | ||
|
|
674acdac66 | ||
|
|
c59152bedf |
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=registry,ref=${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:cache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:cache,mode=max
|
||||
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,8 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, List, Dict, Any, Tuple, Literal
|
||||
import uuid
|
||||
from typing import Optional, List, Dict, Any, Tuple, Literal, Iterator
|
||||
|
||||
from django.db import connection
|
||||
|
||||
@@ -400,7 +401,7 @@ class AssetSearchService:
|
||||
query: str,
|
||||
asset_type: AssetType = 'website',
|
||||
batch_size: int = 1000
|
||||
):
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""
|
||||
流式搜索资产(使用服务端游标,内存友好)
|
||||
|
||||
@@ -425,9 +426,12 @@ class AssetSearchService:
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
|
||||
# 生成唯一的游标名称,避免并发请求冲突
|
||||
cursor_name = f'export_cursor_{uuid.uuid4().hex[:8]}'
|
||||
|
||||
try:
|
||||
# 使用服务端游标,避免一次性加载所有数据到内存
|
||||
with connection.cursor(name='export_cursor') as cursor:
|
||||
with connection.cursor(name=cursor_name) as cursor:
|
||||
cursor.itersize = batch_size
|
||||
cursor.execute(sql, params)
|
||||
columns = [col[0] for col in cursor.description]
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Asset 应用的任务模块
|
||||
|
||||
注意:物化视图刷新已移至 APScheduler 定时任务(apps.engine.scheduler)
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
@@ -34,7 +34,8 @@ 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 django.db import connection, transaction
|
||||
from django.utils.decorators import method_decorator
|
||||
|
||||
from apps.common.response_helpers import success_response, error_response
|
||||
from apps.common.error_codes import ErrorCodes
|
||||
@@ -286,6 +287,9 @@ class AssetSearchExportView(APIView):
|
||||
|
||||
Response:
|
||||
CSV 文件流(使用服务端游标,支持大数据量导出)
|
||||
|
||||
注意:使用 @transaction.non_atomic_requests 装饰器,
|
||||
因为服务端游标不能在事务块内使用。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -312,6 +316,7 @@ class AssetSearchExportView(APIView):
|
||||
|
||||
return headers, formatters
|
||||
|
||||
@method_decorator(transaction.non_atomic_requests)
|
||||
def get(self, request: Request):
|
||||
"""导出搜索结果为 CSV(流式导出,无数量限制)"""
|
||||
from apps.common.utils import generate_csv_rows
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,6 +4,41 @@ from django.db.models import Count
|
||||
from .models import Scan, ScheduledScan
|
||||
|
||||
|
||||
# ==================== 通用验证 Mixin ====================
|
||||
|
||||
class ScanConfigValidationMixin:
|
||||
"""扫描配置验证 Mixin,提供通用的验证方法"""
|
||||
|
||||
def validate_configuration(self, value):
|
||||
"""验证 YAML 配置格式"""
|
||||
import yaml
|
||||
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("configuration 不能为空")
|
||||
|
||||
try:
|
||||
yaml.safe_load(value)
|
||||
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 +117,12 @@ class ScanHistorySerializer(serializers.ModelSerializer):
|
||||
return summary
|
||||
|
||||
|
||||
class QuickScanSerializer(serializers.Serializer):
|
||||
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
|
||||
"""
|
||||
快速扫描序列化器
|
||||
|
||||
功能:
|
||||
- 接收目标列表和引擎配置
|
||||
- 接收目标列表和 YAML 配置
|
||||
- 自动创建/获取目标
|
||||
- 立即发起扫描
|
||||
"""
|
||||
@@ -101,11 +136,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 +175,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 +213,7 @@ class ScheduledScanSerializer(serializers.ModelSerializer):
|
||||
return 'organization' if obj.organization_id else 'target'
|
||||
|
||||
|
||||
class CreateScheduledScanSerializer(serializers.Serializer):
|
||||
class CreateScheduledScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
|
||||
"""创建定时扫描任务序列化器
|
||||
|
||||
扫描模式(二选一):
|
||||
@@ -180,9 +222,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 +264,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(',')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useMemo } from "react"
|
||||
import React, { useState, useMemo, useCallback } from "react"
|
||||
import { Play, Settings2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -13,11 +13,22 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { YamlEditor } from "@/components/ui/yaml-editor"
|
||||
import { LoadingSpinner } from "@/components/loading-spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities } from "@/lib/engine-config"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
|
||||
|
||||
import type { Organization } from "@/types/organization.types"
|
||||
|
||||
@@ -49,6 +60,13 @@ export function InitiateScanDialog({
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [selectedEngineIds, setSelectedEngineIds] = useState<number[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Configuration state management
|
||||
const [configuration, setConfiguration] = useState("")
|
||||
const [isConfigEdited, setIsConfigEdited] = useState(false)
|
||||
const [isYamlValid, setIsYamlValid] = useState(true)
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false)
|
||||
const [pendingEngineChange, setPendingEngineChange] = useState<{ engineId: number; checked: boolean } | null>(null)
|
||||
|
||||
const { data: engines, isLoading, error } = useEngines()
|
||||
|
||||
@@ -66,16 +84,67 @@ export function InitiateScanDialog({
|
||||
return Array.from(allCaps)
|
||||
}, [selectedEngines])
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
// Update configuration when engines change (if not manually edited)
|
||||
const updateConfigurationFromEngines = useCallback((engineIds: number[]) => {
|
||||
if (!engines) return
|
||||
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
|
||||
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
|
||||
setConfiguration(mergedConfig)
|
||||
}, [engines])
|
||||
|
||||
const applyEngineChange = (engineId: number, checked: boolean) => {
|
||||
let newEngineIds: number[]
|
||||
if (checked) {
|
||||
setSelectedEngineIds((prev) => [...prev, engineId])
|
||||
newEngineIds = [...selectedEngineIds, engineId]
|
||||
} else {
|
||||
setSelectedEngineIds((prev) => prev.filter((id) => id !== engineId))
|
||||
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
|
||||
}
|
||||
setSelectedEngineIds(newEngineIds)
|
||||
updateConfigurationFromEngines(newEngineIds)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (isConfigEdited) {
|
||||
// User has edited config, show confirmation
|
||||
setPendingEngineChange({ engineId, checked })
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
applyEngineChange(engineId, checked)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOverwriteConfirm = () => {
|
||||
if (pendingEngineChange) {
|
||||
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleConfigurationChange = (value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
}
|
||||
|
||||
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 +154,9 @@ export function InitiateScanDialog({
|
||||
const response = await initiateScan({
|
||||
organizationId,
|
||||
targetId,
|
||||
configuration,
|
||||
engineIds: selectedEngineIds,
|
||||
engineNames: selectedEngines.map(e => e.name),
|
||||
})
|
||||
|
||||
// 后端返回 201 说明成功创建扫描任务
|
||||
@@ -96,19 +167,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,7 +183,11 @@ export function InitiateScanDialog({
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!isSubmitting) {
|
||||
onOpenChange(newOpen)
|
||||
if (!newOpen) setSelectedEngineIds([])
|
||||
if (!newOpen) {
|
||||
setSelectedEngineIds([])
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,30 +290,49 @@ export function InitiateScanDialog({
|
||||
{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 className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 flex-1">
|
||||
{selectedCapabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isConfigEdited && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{t("configEdited")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4 min-w-0">
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0 min-w-0">
|
||||
<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>
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
disabled={isSubmitting}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</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 className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<h3 className="text-sm font-medium">{t("configTitle")}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4">
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
disabled={isSubmitting}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -254,7 +343,7 @@ export function InitiateScanDialog({
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleInitiate} disabled={!selectedEngineIds.length || isSubmitting}>
|
||||
<Button onClick={handleInitiate} disabled={selectedEngineIds.length === 0 || !configuration.trim() || !isYamlValid || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
@@ -269,6 +358,26 @@ export function InitiateScanDialog({
|
||||
</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,16 +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 { YamlEditor } from "@/components/ui/yaml-editor"
|
||||
import { LoadingSpinner } from "@/components/loading-spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { Zap, Settings2, AlertCircle, ChevronRight, ChevronLeft, Target, Server } from "lucide-react"
|
||||
import { quickScan } from "@/services/scan.service"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities } from "@/lib/engine-config"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
|
||||
import { TargetValidator } from "@/lib/target-validator"
|
||||
import { useEngines } from "@/hooks/use-engines"
|
||||
|
||||
@@ -37,6 +48,13 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
const [targetInput, setTargetInput] = React.useState("")
|
||||
const [selectedEngineIds, setSelectedEngineIds] = React.useState<number[]>([])
|
||||
|
||||
// Configuration state management
|
||||
const [configuration, setConfiguration] = React.useState("")
|
||||
const [isConfigEdited, setIsConfigEdited] = React.useState(false)
|
||||
const [isYamlValid, setIsYamlValid] = React.useState(true)
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = React.useState(false)
|
||||
const [pendingEngineChange, setPendingEngineChange] = React.useState<{ engineId: number; checked: boolean } | null>(null)
|
||||
|
||||
const { data: engines, isLoading, error } = useEngines()
|
||||
|
||||
const lineNumbersRef = React.useRef<HTMLDivElement | null>(null)
|
||||
@@ -70,9 +88,19 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
return Array.from(allCaps)
|
||||
}, [selectedEngines])
|
||||
|
||||
// Update configuration when engines change (if not manually edited)
|
||||
const updateConfigurationFromEngines = React.useCallback((engineIds: number[]) => {
|
||||
if (!engines) return
|
||||
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
|
||||
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
|
||||
setConfiguration(mergedConfig)
|
||||
}, [engines])
|
||||
|
||||
const resetForm = () => {
|
||||
setTargetInput("")
|
||||
setSelectedEngineIds([])
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
@@ -81,16 +109,52 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
if (!isOpen) resetForm()
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
const applyEngineChange = (engineId: number, checked: boolean) => {
|
||||
let newEngineIds: number[]
|
||||
if (checked) {
|
||||
setSelectedEngineIds((prev) => [...prev, engineId])
|
||||
newEngineIds = [...selectedEngineIds, engineId]
|
||||
} else {
|
||||
setSelectedEngineIds((prev) => prev.filter((id) => id !== engineId))
|
||||
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
|
||||
}
|
||||
setSelectedEngineIds(newEngineIds)
|
||||
updateConfigurationFromEngines(newEngineIds)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (isConfigEdited) {
|
||||
// User has edited config, show confirmation
|
||||
setPendingEngineChange({ engineId, checked })
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
applyEngineChange(engineId, checked)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOverwriteConfirm = () => {
|
||||
if (pendingEngineChange) {
|
||||
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleConfigurationChange = (value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
}
|
||||
|
||||
const handleYamlValidationChange = (isValid: boolean) => {
|
||||
setIsYamlValid(isValid)
|
||||
}
|
||||
|
||||
const canProceedToStep2 = validInputs.length > 0 && !hasErrors
|
||||
const canSubmit = selectedEngineIds.length > 0
|
||||
const canSubmit = selectedEngineIds.length > 0 && configuration.trim().length > 0 && isYamlValid
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1 && canProceedToStep2) setStep(2)
|
||||
@@ -118,6 +182,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 +193,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 +209,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)
|
||||
}
|
||||
@@ -338,6 +402,11 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
<h3 className="text-sm font-medium truncate">
|
||||
{selectedEngines.map((e) => e.name).join(", ")}
|
||||
</h3>
|
||||
{isConfigEdited && (
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{t("configEdited")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-3">
|
||||
{selectedCapabilities.length > 0 && (
|
||||
@@ -353,17 +422,30 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
</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>
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
disabled={isSubmitting}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</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 className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">{t("configTitle")}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4">
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
disabled={isSubmitting}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -418,6 +500,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export function ScanProgressDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-fit 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="grid grid-cols-[repeat(2,auto)] 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,11 +9,22 @@ 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"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { YamlEditor } from "@/components/ui/yaml-editor"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -43,6 +54,7 @@ import { useTargets } from "@/hooks/use-targets"
|
||||
import { useEngines } from "@/hooks/use-engines"
|
||||
import { useOrganizations } from "@/hooks/use-organizations"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import { mergeEngineConfigurations, CAPABILITY_CONFIG, parseEngineCapabilities } from "@/lib/engine-config"
|
||||
import type { CreateScheduledScanRequest } from "@/types/scheduled-scan.types"
|
||||
import type { ScanEngine } from "@/types/engine.types"
|
||||
import type { Target } from "@/types/target.types"
|
||||
@@ -124,6 +136,13 @@ export function CreateScheduledScanDialog({
|
||||
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 [pendingEngineChange, setPendingEngineChange] = React.useState<{ engineId: number; checked: boolean } | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -143,6 +162,30 @@ export function CreateScheduledScanDialog({
|
||||
const engines: ScanEngine[] = 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])
|
||||
|
||||
// Get selected capabilities for display
|
||||
const selectedCapabilities = React.useMemo(() => {
|
||||
if (!selectedEngines.length) return []
|
||||
const allCaps = new Set<string>()
|
||||
selectedEngines.forEach((engine) => {
|
||||
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
|
||||
})
|
||||
return Array.from(allCaps)
|
||||
}, [selectedEngines])
|
||||
|
||||
// Update configuration when engines change (if not manually edited)
|
||||
const updateConfigurationFromEngines = React.useCallback((newEngineIds: number[]) => {
|
||||
if (!engines.length) return
|
||||
const selectedEngs = engines.filter(e => newEngineIds.includes(e.id))
|
||||
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
|
||||
setConfiguration(mergedConfig)
|
||||
}, [engines])
|
||||
|
||||
const resetForm = () => {
|
||||
setName("")
|
||||
setEngineIds([])
|
||||
@@ -150,15 +193,53 @@ export function CreateScheduledScanDialog({
|
||||
setSelectedOrgId(null)
|
||||
setSelectedTargetId(null)
|
||||
setCronExpression("0 2 * * *")
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
resetStep()
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
const applyEngineChange = (engineId: number, checked: boolean) => {
|
||||
let newEngineIds: number[]
|
||||
if (checked) {
|
||||
setEngineIds((prev) => [...prev, engineId])
|
||||
newEngineIds = [...engineIds, engineId]
|
||||
} else {
|
||||
setEngineIds((prev) => prev.filter((id) => id !== engineId))
|
||||
newEngineIds = engineIds.filter((id) => id !== engineId)
|
||||
}
|
||||
setEngineIds(newEngineIds)
|
||||
updateConfigurationFromEngines(newEngineIds)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (isConfigEdited) {
|
||||
// User has edited config, show confirmation
|
||||
setPendingEngineChange({ engineId, checked })
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
applyEngineChange(engineId, checked)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOverwriteConfirm = () => {
|
||||
if (pendingEngineChange) {
|
||||
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleConfigurationChange = (value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
}
|
||||
|
||||
const handleYamlValidationChange = (isValid: boolean) => {
|
||||
setIsYamlValid(isValid)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
@@ -180,6 +261,8 @@ export function CreateScheduledScanDialog({
|
||||
case 1:
|
||||
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
|
||||
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
|
||||
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
|
||||
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
|
||||
return true
|
||||
case 2:
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
@@ -193,6 +276,8 @@ export function CreateScheduledScanDialog({
|
||||
case 1:
|
||||
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
|
||||
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
|
||||
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
|
||||
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
|
||||
return true
|
||||
case 2: return true
|
||||
case 3:
|
||||
@@ -216,7 +301,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) {
|
||||
@@ -306,7 +393,7 @@ export function CreateScheduledScanDialog({
|
||||
{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">
|
||||
<div className="border rounded-md p-3 max-h-[150px] overflow-y-auto space-y-2">
|
||||
{engines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("form.noEngine")}</p>
|
||||
) : (
|
||||
@@ -333,6 +420,36 @@ export function CreateScheduledScanDialog({
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("form.scanEngineDesc")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("form.configuration")} *</Label>
|
||||
{isConfigEdited && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("form.configEdited")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{selectedCapabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedCapabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-md overflow-hidden h-[180px]">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("form.configurationDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -504,6 +621,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>
|
||||
)
|
||||
}
|
||||
|
||||
162
frontend/components/ui/yaml-editor.tsx
Normal file
162
frontend/components/ui/yaml-editor.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useCallback } from "react"
|
||||
import Editor from "@monaco-editor/react"
|
||||
import * as yaml from "js-yaml"
|
||||
import { AlertCircle, CheckCircle2 } 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
|
||||
showValidation?: boolean
|
||||
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,
|
||||
showValidation = true,
|
||||
onValidationChange,
|
||||
}: YamlEditorProps) {
|
||||
const t = useTranslations("common.yamlEditor")
|
||||
const { currentTheme } = useColorTheme()
|
||||
const [isEditorReady, setIsEditorReady] = useState(false)
|
||||
const [yamlError, setYamlError] = useState<{ message: string; line?: number; column?: number } | null>(null)
|
||||
|
||||
// Validate YAML syntax
|
||||
const validateYaml = useCallback((content: string) => {
|
||||
if (!content.trim()) {
|
||||
setYamlError(null)
|
||||
onValidationChange?.(true)
|
||||
return true
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
// 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(() => {
|
||||
setIsEditorReady(true)
|
||||
// Validate initial content
|
||||
validateYaml(value)
|
||||
}, [validateYaml, value])
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Validation status */}
|
||||
{showValidation && (
|
||||
<div className="flex items-center justify-end px-2 py-1 border-b bg-muted/30">
|
||||
{value.trim() && (
|
||||
yamlError ? (
|
||||
<div className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span>{t("syntaxError")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span>{t("syntaxValid")}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className={cn("flex-1 overflow-hidden", yamlError ? 'border-destructive' : '')}>
|
||||
<Editor
|
||||
height={height}
|
||||
defaultLanguage="yaml"
|
||||
value={value}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
theme={currentTheme.isDark ? "vs-dark" : "light"}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
wordWrap: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
folding: true,
|
||||
foldingStrategy: "indentation",
|
||||
showFoldingControls: "mouseover",
|
||||
bracketPairColorization: {
|
||||
enabled: true,
|
||||
},
|
||||
padding: {
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
},
|
||||
readOnly: disabled,
|
||||
placeholder: placeholder,
|
||||
}}
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full bg-muted/30">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-xs text-muted-foreground">{t("loading")}</p>
|
||||
</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,12 @@
|
||||
"website": "Website",
|
||||
"description": "Description"
|
||||
},
|
||||
"yamlEditor": {
|
||||
"syntaxError": "Syntax Error",
|
||||
"syntaxValid": "Syntax Valid",
|
||||
"errorLocation": "Line {line}, Column {column}",
|
||||
"loading": "Loading editor..."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Switch to light mode",
|
||||
"switchToDark": "Switch to dark mode",
|
||||
@@ -749,8 +755,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",
|
||||
@@ -803,7 +815,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 +1424,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",
|
||||
@@ -1741,6 +1762,7 @@
|
||||
"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,12 @@
|
||||
"website": "官网",
|
||||
"description": "描述"
|
||||
},
|
||||
"yamlEditor": {
|
||||
"syntaxError": "语法错误",
|
||||
"syntaxValid": "语法正确",
|
||||
"errorLocation": "第 {line} 行,第 {column} 列",
|
||||
"loading": "加载编辑器..."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "切换到亮色模式",
|
||||
"switchToDark": "切换到暗色模式",
|
||||
@@ -749,8 +755,14 @@
|
||||
"taskNameRequired": "请输入任务名称",
|
||||
"scanEngine": "扫描引擎",
|
||||
"scanEnginePlaceholder": "选择扫描引擎",
|
||||
"scanEngineDesc": "选择要使用的扫描引擎配置",
|
||||
"scanEngineDesc": "选择引擎可快速填充配置,也可直接编辑配置",
|
||||
"scanEngineRequired": "请选择扫描引擎",
|
||||
"configuration": "扫描配置",
|
||||
"configurationPlaceholder": "请输入 YAML 格式的扫描配置...",
|
||||
"configurationDesc": "YAML 格式的扫描配置,可选择引擎自动填充或手动编辑",
|
||||
"configurationRequired": "请输入扫描配置",
|
||||
"yamlInvalid": "YAML 配置格式错误,请检查语法",
|
||||
"configEdited": "已编辑",
|
||||
"selectScanMode": "选择扫描模式",
|
||||
"organizationScan": "组织扫描",
|
||||
"organizationScanDesc": "选择组织,执行时动态获取其下所有目标",
|
||||
@@ -803,7 +815,14 @@
|
||||
},
|
||||
"toast": {
|
||||
"selectOrganization": "请选择一个组织",
|
||||
"selectTarget": "请选择一个扫描目标"
|
||||
"selectTarget": "请选择一个扫描目标",
|
||||
"configConflict": "配置冲突"
|
||||
},
|
||||
"overwriteConfirm": {
|
||||
"title": "覆盖配置确认",
|
||||
"description": "您已手动编辑了配置,切换引擎将覆盖当前配置。确定要继续吗?",
|
||||
"cancel": "取消",
|
||||
"confirm": "确定覆盖"
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
@@ -1405,6 +1424,8 @@
|
||||
"initiateScanFailed": "发起扫描失败",
|
||||
"noScansCreated": "未创建任何扫描任务",
|
||||
"unknownError": "未知错误",
|
||||
"noEngineSelected": "请选择至少一个扫描引擎",
|
||||
"emptyConfig": "扫描配置不能为空",
|
||||
"engineNameRequired": "请输入引擎名称",
|
||||
"configRequired": "配置内容不能为空",
|
||||
"yamlSyntaxError": "YAML 语法错误",
|
||||
@@ -1741,6 +1762,7 @@
|
||||
"noValidTarget": "请输入至少一个有效目标",
|
||||
"hasInvalidInputs": "存在 {count} 个无效输入,请修正后继续",
|
||||
"selectEngine": "请选择扫描引擎",
|
||||
"emptyConfig": "扫描配置不能为空",
|
||||
"getEnginesFailed": "获取引擎列表失败",
|
||||
"createFailed": "创建扫描任务失败",
|
||||
"createSuccess": "已创建 {count} 个扫描任务",
|
||||
|
||||
@@ -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