Compare commits

...

15 Commits

Author SHA1 Message Date
yyhuni
8a8062a12d refactor(scan): rename merged_configuration to yaml_configuration
- Rename `merged_configuration` field to `yaml_configuration` in Scan and ScheduledScan models for clarity
- Update all references across scan repositories, services, views, and serializers
- Update database migration to reflect field name change with improved help text
- Update frontend components to use new field naming convention
- Add YAML editor component for improved configuration editing in UI
- Update engine configuration retrieval in initiate_scan_flow to use new field name
- Remove unused asset tasks __init__.py module
- Simplify README feedback section for better clarity
- Update frontend type definitions and internationalization messages for consistency
2026-01-03 19:50:20 +08:00
yyhuni
55908a2da5 fix(asset,scan): improve decorator usage and dialog layout
- Fix transaction.non_atomic_requests decorator usage in AssetSearchExportView by wrapping with method_decorator for proper class-based view compatibility
- Update scan progress dialog to use flexible width (sm:max-w-fit sm:min-w-[450px]) instead of fixed width for better responsiveness
- Refactor engine names display from single Badge to grid layout with multiple badges for improved readability when multiple engines are present
- Add proper spacing and alignment adjustments (gap-4, items-start) to accommodate multi-line engine badge display
- Add text-xs and whitespace-nowrap to engine badges for consistent styling in grid layout
2026-01-03 18:46:44 +08:00
github-actions[bot]
22a7d4f091 chore: bump version to v1.3.10-dev 2026-01-03 10:45:32 +00:00
yyhuni
f287f18134 更新锁定镜像 2026-01-03 18:33:25 +08:00
yyhuni
de27230b7a 更新构建ci 2026-01-03 18:28:57 +08:00
github-actions[bot]
15a6295189 chore: bump version to v1.3.8-dev 2026-01-03 10:24:17 +00:00
yyhuni
674acdac66 refactor(asset): move database extension initialization to migrations
- Remove pg_trgm and pg_ivm extension setup from AssetConfig.ready() method
- Move extension creation to migration 0002 using RunSQL operations
- Add pg_trgm extension creation for text search index support
- Add pg_ivm extension creation for IMMV incremental maintenance
- Generate unique cursor names in search_service to prevent concurrent request conflicts
- Add @transaction.non_atomic_requests decorator to export view for server-side cursor compatibility
- Simplify app initialization by delegating extension setup to database migrations
- Improve thread safety and concurrency handling for streaming exports
2026-01-03 18:20:27 +08:00
github-actions[bot]
c59152bedf chore: bump version to v1.3.7-dev 2026-01-03 09:56:39 +00:00
yyhuni
b4037202dc feat: use registry cache for faster builds 2026-01-03 17:35:54 +08:00
yyhuni
4b4f9862bf ci(docker): add postgres image build configuration and update image tags
- Add xingrin-postgres image build job to docker-build workflow for multi-platform support (linux/amd64,linux/arm64)
- Update docker-compose.dev.yml to use IMAGE_TAG variable with dev as default fallback
- Update docker-compose.yml to use IMAGE_TAG variable with required validation
- Replace hardcoded postgres image tag (15) with dynamic IMAGE_TAG for better version management
- Enable flexible image tagging across development and production environments
2026-01-03 17:26:34 +08:00
github-actions[bot]
1c42e4978f chore: bump version to v1.3.5-dev 2026-01-03 08:44:06 +00:00
github-actions[bot]
57bab63997 chore: bump version to v1.3.3-dev 2026-01-03 05:55:07 +00:00
github-actions[bot]
b1f0f18ac0 chore: bump version to v1.3.4-dev 2026-01-03 05:54:50 +00:00
yyhuni
ccee5471b8 docs(readme): add notification push service documentation
- Add notification push service feature to visualization interface section
- Document support for real-time WeChat Work, Telegram, and Discord message push
- Enhance feature list clarity for notification capabilities
2026-01-03 13:34:36 +08:00
yyhuni
0ccd362535 优化下载逻辑 2026-01-03 13:32:58 +08:00
33 changed files with 1165 additions and 412 deletions

View File

@@ -19,7 +19,8 @@ permissions:
contents: write contents: write
jobs: jobs:
build: # AMD64 构建(原生 x64 runner
build-amd64:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
@@ -27,39 +28,30 @@ jobs:
- image: xingrin-server - image: xingrin-server
dockerfile: docker/server/Dockerfile dockerfile: docker/server/Dockerfile
context: . context: .
platforms: linux/amd64,linux/arm64
- image: xingrin-frontend - image: xingrin-frontend
dockerfile: docker/frontend/Dockerfile dockerfile: docker/frontend/Dockerfile
context: . context: .
platforms: linux/amd64 # ARM64 构建时 Next.js 在 QEMU 下会崩溃
- image: xingrin-worker - image: xingrin-worker
dockerfile: docker/worker/Dockerfile dockerfile: docker/worker/Dockerfile
context: . context: .
platforms: linux/amd64,linux/arm64
- image: xingrin-nginx - image: xingrin-nginx
dockerfile: docker/nginx/Dockerfile dockerfile: docker/nginx/Dockerfile
context: . context: .
platforms: linux/amd64,linux/arm64
- image: xingrin-agent - image: xingrin-agent
dockerfile: docker/agent/Dockerfile dockerfile: docker/agent/Dockerfile
context: . context: .
platforms: linux/amd64,linux/arm64 - image: xingrin-postgres
dockerfile: docker/postgres/Dockerfile
context: docker/postgres
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Free disk space (for large builds like worker) - name: Free disk space
run: | run: |
echo "=== Before cleanup ===" sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
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 docker image prune -af sudo docker image prune -af
echo "=== After cleanup ==="
df -h
- name: Generate SSL certificates for nginx build - name: Generate SSL certificates for nginx build
if: matrix.image == 'xingrin-nginx' if: matrix.image == 'xingrin-nginx'
@@ -69,10 +61,6 @@ jobs:
-keyout docker/nginx/ssl/privkey.pem \ -keyout docker/nginx/ssl/privkey.pem \
-out docker/nginx/ssl/fullchain.pem \ -out docker/nginx/ssl/fullchain.pem \
-subj "/CN=localhost" -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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -83,7 +71,120 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 id: version
run: | run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then if [[ $GITHUB_REF == refs/tags/* ]]; then
@@ -94,28 +195,27 @@ jobs:
echo "IS_RELEASE=false" >> $GITHUB_OUTPUT echo "IS_RELEASE=false" >> $GITHUB_OUTPUT
fi fi
- name: Build and push - name: Create and push multi-arch manifest
uses: docker/build-push-action@v5 run: |
with: VERSION=${{ steps.version.outputs.VERSION }}
context: ${{ matrix.context }} IMAGE=${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms }} docker manifest create ${IMAGE}:${VERSION} \
push: true ${IMAGE}:${VERSION}-amd64 \
tags: | ${IMAGE}:${VERSION}-arm64
${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:${{ steps.version.outputs.VERSION }} docker manifest push ${IMAGE}:${VERSION}
${{ steps.version.outputs.IS_RELEASE == 'true' && format('{0}/{1}:latest', env.IMAGE_PREFIX, matrix.image) || '' }}
build-args: | if [[ "${{ steps.version.outputs.IS_RELEASE }}" == "true" ]]; then
IMAGE_TAG=${{ steps.version.outputs.VERSION }} docker manifest create ${IMAGE}:latest \
cache-from: type=gha,scope=${{ matrix.image }} ${IMAGE}:${VERSION}-amd64 \
cache-to: type=gha,mode=max,scope=${{ matrix.image }} ${IMAGE}:${VERSION}-arm64
provenance: false docker manifest push ${IMAGE}:latest
sbom: false fi
# 所有镜像构建成功后,更新 VERSION 文件 # 更新 VERSION 文件
# 根据 tag 所在的分支更新对应分支的 VERSION 文件
update-version: update-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: merge-manifests
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Checkout repository - name: Checkout repository

View File

@@ -13,14 +13,14 @@
<p align="center"> <p align="center">
<a href="#-功能特性">功能特性</a> • <a href="#-功能特性">功能特性</a> •
<a href="#-全局资产搜索">资产搜索</a> •
<a href="#-快速开始">快速开始</a> • <a href="#-快速开始">快速开始</a> •
<a href="#-文档">文档</a> • <a href="#-文档">文档</a> •
<a href="#-技术栈">技术栈</a> •
<a href="#-反馈与贡献">反馈与贡献</a> <a href="#-反馈与贡献">反馈与贡献</a>
</p> </p>
<p align="center"> <p align="center">
<sub>🔍 关键词: ASM | 攻击面管理 | 漏洞扫描 | 资产发现 | Bug Bounty | 渗透测试 | Nuclei | 子域名枚举 | EASM</sub> <sub>🔍 关键词: ASM | 攻击面管理 | 漏洞扫描 | 资产发现 | 资产搜索 | Bug Bounty | 渗透测试 | Nuclei | 子域名枚举 | EASM</sub>
</p> </p>
--- ---
@@ -162,9 +162,34 @@ flowchart TB
W3 -.心跳上报.-> REDIS W3 -.心跳上报.-> REDIS
``` ```
### 🔎 全局资产搜索
- **多类型搜索** - 支持 Website 和 Endpoint 两种资产类型
- **表达式语法** - 支持 `=`(模糊)、`==`(精确)、`!=`(不等于)操作符
- **逻辑组合** - 支持 `&&` (AND) 和 `||` (OR) 逻辑组合
- **多字段查询** - 支持 host、url、title、tech、status、body、header 字段
- **CSV 导出** - 流式导出全部搜索结果,无数量限制
#### 搜索语法示例
```bash
# 基础搜索
host="api" # host 包含 "api"
status=="200" # 状态码精确等于 200
tech="nginx" # 技术栈包含 nginx
# 组合搜索
host="api" && status=="200" # host 包含 api 且状态码为 200
tech="vue" || tech="react" # 技术栈包含 vue 或 react
# 复杂查询
host="admin" && tech="php" && status=="200"
url="/api/v1" && status!="404"
```
### 📊 可视化界面 ### 📊 可视化界面
- **数据统计** - 资产/漏洞统计仪表盘 - **数据统计** - 资产/漏洞统计仪表盘
- **实时通知** - WebSocket 消息推送 - **实时通知** - WebSocket 消息推送
- **通知推送** - 实时企业微信tgdiscard消息推送服务
--- ---
@@ -172,7 +197,7 @@ flowchart TB
### 环境要求 ### 环境要求
- **操作系统**: Ubuntu 20.04+ / Debian 11+ (推荐) - **操作系统**: Ubuntu 20.04+ / Debian 11+
- **硬件**: 2核 4G 内存起步20GB+ 磁盘空间 - **硬件**: 2核 4G 内存起步20GB+ 磁盘空间
### 一键安装 ### 一键安装
@@ -197,6 +222,7 @@ sudo ./install.sh --mirror
### 访问服务 ### 访问服务
- **Web 界面**: `https://ip:8083` - **Web 界面**: `https://ip:8083`
- **默认账号**: admin / admin首次登录后请修改密码
### 常用命令 ### 常用命令
@@ -216,13 +242,9 @@ sudo ./uninstall.sh
## 🤝 反馈与贡献 ## 🤝 反馈与贡献
- 🐛 **如果发现 Bug** 可以点击右边链接进行提交 [Issue](https://github.com/yyhuni/xingrin/issues) - 💡 **发现 Bug有新想法比如UI设计功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues) 或者公众号私信
- 💡 **有新想法比如UI设计功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues)
## 📧 联系 ## 📧 联系
- 目前版本就我个人使用,可能会有很多边界问题
- 如有问题,建议,其他,优先提交[Issue](https://github.com/yyhuni/xingrin/issues),也可以直接给我的公众号发消息,我都会回复的
- 微信公众号: **塔罗安全学苑** - 微信公众号: **塔罗安全学苑**
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200"> <img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">

View File

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

View File

@@ -1,106 +1,6 @@
import logging
import sys
from django.apps import AppConfig from django.apps import AppConfig
logger = logging.getLogger(__name__)
class AssetConfig(AppConfig): class AssetConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.asset' 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}")

View File

@@ -18,7 +18,13 @@ class Migration(migrations.Migration):
] ]
operations = [ 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( migrations.RunSQL(
sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;", sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;",
reverse_sql="-- pg_ivm extension kept for other uses" reverse_sql="-- pg_ivm extension kept for other uses"

View File

@@ -11,7 +11,8 @@
import logging import logging
import re 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 from django.db import connection
@@ -394,3 +395,49 @@ class AssetSearchService:
except Exception as e: except Exception as e:
logger.error(f"统计查询失败: {e}") logger.error(f"统计查询失败: {e}")
raise raise
def search_iter(
self,
query: str,
asset_type: AssetType = 'website',
batch_size: int = 1000
) -> Iterator[Dict[str, Any]]:
"""
流式搜索资产(使用服务端游标,内存友好)
Args:
query: 搜索查询字符串
asset_type: 资产类型 ('website''endpoint')
batch_size: 每批获取的数量
Yields:
Dict: 单条搜索结果
"""
where_clause, params = SearchQueryParser.parse(query)
# 根据资产类型选择视图和字段
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
"""
# 生成唯一的游标名称,避免并发请求冲突
cursor_name = f'export_cursor_{uuid.uuid4().hex[:8]}'
try:
# 使用服务端游标,避免一次性加载所有数据到内存
with connection.cursor(name=cursor_name) as cursor:
cursor.itersize = batch_size
cursor.execute(sql, params)
columns = [col[0] for col in cursor.description]
for row in cursor:
yield dict(zip(columns, row))
except Exception as e:
logger.error(f"流式搜索查询失败: {e}, SQL: {sql}, params: {params}")
raise

View File

@@ -1,7 +0,0 @@
"""
Asset 应用的任务模块
注意:物化视图刷新已移至 APScheduler 定时任务apps.engine.scheduler
"""
__all__ = []

View File

@@ -28,15 +28,14 @@
import logging import logging
import json import json
import csv
from io import StringIO
from datetime import datetime from datetime import datetime
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from rest_framework import status from rest_framework import status
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.request import Request from rest_framework.request import Request
from django.http import StreamingHttpResponse 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.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes from apps.common.error_codes import ErrorCodes
@@ -287,76 +286,41 @@ class AssetSearchExportView(APIView):
asset_type: 资产类型 ('website''endpoint',默认 'website') asset_type: 资产类型 ('website''endpoint',默认 'website')
Response: Response:
CSV 文件流 CSV 文件流(使用服务端游标,支持大数据量导出)
"""
# 导出数量限制 注意:使用 @transaction.non_atomic_requests 装饰器,
MAX_EXPORT_ROWS = 10000 因为服务端游标不能在事务块内使用。
"""
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.service = AssetSearchService() self.service = AssetSearchService()
def _parse_headers(self, headers_data) -> str: def _get_headers_and_formatters(self, asset_type: str):
"""解析响应头为字符串""" """获取 CSV 表头和格式化器"""
if not headers_data: from apps.common.utils import format_datetime, format_list_field
return ''
try:
headers = json.loads(headers_data)
return '; '.join(f'{k}: {v}' for k, v in headers.items())
except (json.JSONDecodeError, TypeError):
return str(headers_data)
def _generate_csv(self, results: list, asset_type: str):
"""生成 CSV 内容的生成器"""
# 定义列
if asset_type == 'website': if asset_type == 'website':
columns = ['url', 'host', 'title', 'status_code', 'content_type', 'content_length', headers = ['url', 'host', 'title', 'status_code', 'content_type', 'content_length',
'webserver', 'location', 'tech', 'vhost', 'created_at'] 'webserver', 'location', 'tech', 'vhost', 'created_at']
headers = ['URL', 'Host', 'Title', 'Status', 'Content-Type', 'Content-Length',
'Webserver', 'Location', 'Technologies', 'VHost', 'Created At']
else: else:
columns = ['url', 'host', 'title', 'status_code', 'content_type', 'content_length', headers = ['url', 'host', 'title', 'status_code', 'content_type', 'content_length',
'webserver', 'location', 'tech', 'matched_gf_patterns', 'vhost', 'created_at'] 'webserver', 'location', 'tech', 'matched_gf_patterns', 'vhost', 'created_at']
headers = ['URL', 'Host', 'Title', 'Status', 'Content-Type', 'Content-Length',
'Webserver', 'Location', 'Technologies', 'GF Patterns', 'VHost', 'Created At']
# 写入 BOM 和表头 formatters = {
output = StringIO() 'created_at': format_datetime,
writer = csv.writer(output) 'tech': lambda x: format_list_field(x, separator='; '),
'matched_gf_patterns': lambda x: format_list_field(x, separator='; '),
'vhost': lambda x: 'true' if x else ('false' if x is False else ''),
}
# UTF-8 BOM return headers, formatters
yield '\ufeff'
# 表头
writer.writerow(headers)
yield output.getvalue()
output.seek(0)
output.truncate(0)
# 数据行
for result in results:
row = []
for col in columns:
value = result.get(col)
if col == 'tech' or col == 'matched_gf_patterns':
# 数组转字符串
row.append('; '.join(value) if value else '')
elif col == 'created_at':
# 日期格式化
row.append(value.strftime('%Y-%m-%d %H:%M:%S') if value else '')
elif col == 'vhost':
row.append('true' if value else 'false' if value is False else '')
else:
row.append(str(value) if value is not None else '')
writer.writerow(row)
yield output.getvalue()
output.seek(0)
output.truncate(0)
@method_decorator(transaction.non_atomic_requests)
def get(self, request: Request): def get(self, request: Request):
"""导出搜索结果为 CSV""" """导出搜索结果为 CSV(流式导出,无数量限制)"""
from apps.common.utils import generate_csv_rows
# 获取搜索查询 # 获取搜索查询
query = request.query_params.get('q', '').strip() query = request.query_params.get('q', '').strip()
@@ -376,23 +340,28 @@ class AssetSearchExportView(APIView):
status_code=status.HTTP_400_BAD_REQUEST status_code=status.HTTP_400_BAD_REQUEST
) )
# 获取搜索结果(限制数量 # 检查是否有结果(快速检查,避免空导出
results = self.service.search(query, asset_type, limit=self.MAX_EXPORT_ROWS) total = self.service.count(query, asset_type)
if total == 0:
if not results:
return error_response( return error_response(
code=ErrorCodes.NOT_FOUND, code=ErrorCodes.NOT_FOUND,
message='No results to export', message='No results to export',
status_code=status.HTTP_404_NOT_FOUND status_code=status.HTTP_404_NOT_FOUND
) )
# 获取表头和格式化器
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') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'search_{asset_type}_{timestamp}.csv' filename = f'search_{asset_type}_{timestamp}.csv'
# 返回流式响应 # 返回流式响应
response = StreamingHttpResponse( response = StreamingHttpResponse(
self._generate_csv(results, asset_type), generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8' content_type='text/csv; charset=utf-8'
) )
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'

View File

@@ -115,7 +115,7 @@ def initiate_scan_flow(
# ==================== Task 2: 获取引擎配置 ==================== # ==================== Task 2: 获取引擎配置 ====================
from apps.scan.models import Scan from apps.scan.models import Scan
scan = Scan.objects.get(id=scan_id) scan = Scan.objects.get(id=scan_id)
engine_config = scan.merged_configuration engine_config = scan.yaml_configuration
# 使用 engine_names 进行显示 # 使用 engine_names 进行显示
display_engine_name = ', '.join(scan.engine_names) if scan.engine_names else engine_name display_engine_name = ', '.join(scan.engine_names) if scan.engine_names else engine_name

View File

@@ -57,7 +57,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(primary_key=True, serialize=False)), ('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_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"]')), ('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='任务创建时间')), ('created_at', models.DateTimeField(auto_now_add=True, help_text='任务创建时间')),
('stopped_at', models.DateTimeField(blank=True, help_text='扫描结束时间', null=True)), ('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)), ('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)), ('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_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"]')), ('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)), ('cron_expression', models.CharField(default='0 2 * * *', help_text='Cron 表达式,格式:分 时 日 月 周', max_length=100)),
('is_enabled', models.BooleanField(db_index=True, default=True, help_text='是否启用')), ('is_enabled', models.BooleanField(db_index=True, default=True, help_text='是否启用')),
('run_count', models.IntegerField(default=0, help_text='已执行次数')), ('run_count', models.IntegerField(default=0, help_text='已执行次数')),

View File

@@ -30,9 +30,9 @@ class Scan(models.Model):
default=list, default=list,
help_text='引擎名称列表,如 ["引擎A", "引擎B"]' help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
) )
merged_configuration = models.TextField( yaml_configuration = models.TextField(
default='', default='',
help_text='合并后的 YAML 配置' help_text='YAML 格式的扫描配置'
) )
created_at = models.DateTimeField(auto_now_add=True, help_text='任务创建时间') created_at = models.DateTimeField(auto_now_add=True, help_text='任务创建时间')
@@ -136,9 +136,9 @@ class ScheduledScan(models.Model):
default=list, default=list,
help_text='引擎名称列表,如 ["引擎A", "引擎B"]' help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
) )
merged_configuration = models.TextField( yaml_configuration = models.TextField(
default='', default='',
help_text='合并后的 YAML 配置' help_text='YAML 格式的扫描配置'
) )
# 关联的组织(组织扫描模式:执行时动态获取组织下所有目标) # 关联的组织(组织扫描模式:执行时动态获取组织下所有目标)

View File

@@ -104,7 +104,7 @@ class DjangoScanRepository:
target: Target, target: Target,
engine_ids: List[int], engine_ids: List[int],
engine_names: List[str], engine_names: List[str],
merged_configuration: str, yaml_configuration: str,
results_dir: str, results_dir: str,
status: ScanStatus = ScanStatus.INITIATED status: ScanStatus = ScanStatus.INITIATED
) -> Scan: ) -> Scan:
@@ -115,7 +115,7 @@ class DjangoScanRepository:
target: 扫描目标 target: 扫描目标
engine_ids: 引擎 ID 列表 engine_ids: 引擎 ID 列表
engine_names: 引擎名称列表 engine_names: 引擎名称列表
merged_configuration: 合并后的 YAML 配置 yaml_configuration: YAML 格式的扫描配置
results_dir: 结果目录 results_dir: 结果目录
status: 初始状态 status: 初始状态
@@ -126,7 +126,7 @@ class DjangoScanRepository:
target=target, target=target,
engine_ids=engine_ids, engine_ids=engine_ids,
engine_names=engine_names, engine_names=engine_names,
merged_configuration=merged_configuration, yaml_configuration=yaml_configuration,
results_dir=results_dir, results_dir=results_dir,
status=status, status=status,
container_ids=[] container_ids=[]

View File

@@ -31,7 +31,7 @@ class ScheduledScanDTO:
name: str = '' name: str = ''
engine_ids: List[int] = None # 多引擎支持 engine_ids: List[int] = None # 多引擎支持
engine_names: List[str] = None # 引擎名称列表 engine_names: List[str] = None # 引擎名称列表
merged_configuration: str = '' # 合并后的配置 yaml_configuration: str = '' # YAML 格式的扫描配置
organization_id: Optional[int] = None # 组织扫描模式 organization_id: Optional[int] = None # 组织扫描模式
target_id: Optional[int] = None # 目标扫描模式 target_id: Optional[int] = None # 目标扫描模式
cron_expression: Optional[str] = None cron_expression: Optional[str] = None
@@ -114,7 +114,7 @@ class DjangoScheduledScanRepository:
name=dto.name, name=dto.name,
engine_ids=dto.engine_ids, engine_ids=dto.engine_ids,
engine_names=dto.engine_names, engine_names=dto.engine_names,
merged_configuration=dto.merged_configuration, yaml_configuration=dto.yaml_configuration,
organization_id=dto.organization_id, # 组织扫描模式 organization_id=dto.organization_id, # 组织扫描模式
target_id=dto.target_id if not dto.organization_id else None, # 目标扫描模式 target_id=dto.target_id if not dto.organization_id else None, # 目标扫描模式
cron_expression=dto.cron_expression, cron_expression=dto.cron_expression,
@@ -147,8 +147,8 @@ class DjangoScheduledScanRepository:
scheduled_scan.engine_ids = dto.engine_ids scheduled_scan.engine_ids = dto.engine_ids
if dto.engine_names is not None: if dto.engine_names is not None:
scheduled_scan.engine_names = dto.engine_names scheduled_scan.engine_names = dto.engine_names
if dto.merged_configuration is not None: if dto.yaml_configuration is not None:
scheduled_scan.merged_configuration = dto.merged_configuration scheduled_scan.yaml_configuration = dto.yaml_configuration
if dto.cron_expression is not None: if dto.cron_expression is not None:
scheduled_scan.cron_expression = dto.cron_expression scheduled_scan.cron_expression = dto.cron_expression
if dto.is_enabled is not None: if dto.is_enabled is not None:

View File

@@ -4,6 +4,41 @@ from django.db.models import Count
from .models import Scan, ScheduledScan 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): class ScanSerializer(serializers.ModelSerializer):
"""扫描任务序列化器""" """扫描任务序列化器"""
target_name = serializers.SerializerMethodField() target_name = serializers.SerializerMethodField()
@@ -82,12 +117,12 @@ class ScanHistorySerializer(serializers.ModelSerializer):
return summary return summary
class QuickScanSerializer(serializers.Serializer): class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
""" """
快速扫描序列化器 快速扫描序列化器
功能: 功能:
- 接收目标列表和引擎配置 - 接收目标列表和 YAML 配置
- 自动创建/获取目标 - 自动创建/获取目标
- 立即发起扫描 - 立即发起扫描
""" """
@@ -101,11 +136,24 @@ class QuickScanSerializer(serializers.Serializer):
help_text='目标列表,每个目标包含 name 字段' help_text='目标列表,每个目标包含 name 字段'
) )
# 扫描引擎 ID 列表 # YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填,用于记录和显示)
engine_ids = serializers.ListField( engine_ids = serializers.ListField(
child=serializers.IntegerField(), child=serializers.IntegerField(),
required=True, required=True,
help_text='使用的扫描引擎 ID 列表 (必填)' help_text='使用的扫描引擎 ID 列表必填'
)
# 引擎名称列表(必填,用于记录和显示)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
) )
def validate_targets(self, value): def validate_targets(self, value):
@@ -127,12 +175,6 @@ class QuickScanSerializer(serializers.Serializer):
raise serializers.ValidationError(f"{idx + 1} 个目标的 name 不能为空") raise serializers.ValidationError(f"{idx + 1} 个目标的 name 不能为空")
return value 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' 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='任务名称') name = serializers.CharField(max_length=200, help_text='任务名称')
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填,用于记录和显示)
engine_ids = serializers.ListField( engine_ids = serializers.ListField(
child=serializers.IntegerField(), 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='是否立即启用') is_enabled = serializers.BooleanField(default=True, help_text='是否立即启用')
def validate_engine_ids(self, value): def validate(self, data):
"""验证引擎 ID 列表""" """验证 organization_id 和 target_id 互斥"""
if not value: organization_id = data.get('organization_id')
raise serializers.ValidationError("engine_ids 不能为空") target_id = data.get('target_id')
return value
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): def validate(self, data):
"""验证 organization_id 和 target_id 互斥""" """验证 organization_id 和 target_id 互斥"""

View File

@@ -282,7 +282,7 @@ class ScanCreationService:
targets: List[Target], targets: List[Target],
engine_ids: List[int], engine_ids: List[int],
engine_names: List[str], engine_names: List[str],
merged_configuration: str, yaml_configuration: str,
scheduled_scan_name: str | None = None scheduled_scan_name: str | None = None
) -> List[Scan]: ) -> List[Scan]:
""" """
@@ -292,7 +292,7 @@ class ScanCreationService:
targets: 目标列表 targets: 目标列表
engine_ids: 引擎 ID 列表 engine_ids: 引擎 ID 列表
engine_names: 引擎名称列表 engine_names: 引擎名称列表
merged_configuration: 合并后的 YAML 配置 yaml_configuration: YAML 格式的扫描配置
scheduled_scan_name: 定时扫描任务名称(可选,用于通知显示) scheduled_scan_name: 定时扫描任务名称(可选,用于通知显示)
Returns: Returns:
@@ -312,7 +312,7 @@ class ScanCreationService:
target=target, target=target,
engine_ids=engine_ids, engine_ids=engine_ids,
engine_names=engine_names, engine_names=engine_names,
merged_configuration=merged_configuration, yaml_configuration=yaml_configuration,
results_dir=scan_workspace_dir, results_dir=scan_workspace_dir,
status=ScanStatus.INITIATED, status=ScanStatus.INITIATED,
container_ids=[], container_ids=[],

View File

@@ -117,12 +117,12 @@ class ScanService:
targets: List[Target], targets: List[Target],
engine_ids: List[int], engine_ids: List[int],
engine_names: List[str], engine_names: List[str],
merged_configuration: str, yaml_configuration: str,
scheduled_scan_name: str | None = None scheduled_scan_name: str | None = None
) -> List[Scan]: ) -> List[Scan]:
"""批量创建扫描任务(委托给 ScanCreationService""" """批量创建扫描任务(委托给 ScanCreationService"""
return self.creation_service.create_scans( 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 ==================== # ==================== 状态管理方法(委托给 ScanStateService ====================

View File

@@ -54,7 +54,7 @@ class ScheduledScanService:
def create(self, dto: ScheduledScanDTO) -> ScheduledScan: def create(self, dto: ScheduledScanDTO) -> ScheduledScan:
""" """
创建定时扫描任务 创建定时扫描任务(使用引擎 ID 合并配置)
流程: 流程:
1. 验证参数 1. 验证参数
@@ -88,7 +88,7 @@ class ScheduledScanService:
# 设置 DTO 的合并配置和引擎名称 # 设置 DTO 的合并配置和引擎名称
dto.engine_names = engine_names dto.engine_names = engine_names
dto.merged_configuration = merged_configuration dto.yaml_configuration = merged_configuration
# 3. 创建数据库记录 # 3. 创建数据库记录
scheduled_scan = self.repo.create(dto) scheduled_scan = self.repo.create(dto)
@@ -107,12 +107,49 @@ class ScheduledScanService:
return scheduled_scan return scheduled_scan
def _validate_create_dto(self, dto: ScheduledScanDTO) -> None: def create_with_configuration(self, dto: ScheduledScanDTO) -> ScheduledScan:
"""验证创建 DTO""" """
from apps.targets.repositories import DjangoOrganizationRepository 创建定时扫描任务(直接使用前端传递的配置)
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: if not dto.engine_ids:
raise ValidationError('必须选择扫描引擎') raise ValidationError('必须选择扫描引擎')
@@ -121,6 +158,21 @@ class ScheduledScanService:
for engine_id in dto.engine_ids: for engine_id in dto.engine_ids:
if not self.engine_repo.get_by_id(engine_id): if not self.engine_repo.get_by_id(engine_id):
raise ValidationError(f'扫描引擎 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 互斥) # 验证扫描模式organization_id 和 target_id 互斥)
if not dto.organization_id and not dto.target_id: if not dto.organization_id and not dto.target_id:
@@ -178,7 +230,7 @@ class ScheduledScanService:
merged_configuration = merge_engine_configs(engines) merged_configuration = merge_engine_configs(engines)
dto.engine_names = engine_names dto.engine_names = engine_names
dto.merged_configuration = merged_configuration dto.yaml_configuration = merged_configuration
# 更新数据库记录 # 更新数据库记录
scheduled_scan = self.repo.update(scheduled_scan_id, dto) scheduled_scan = self.repo.update(scheduled_scan_id, dto)
@@ -329,7 +381,7 @@ class ScheduledScanService:
立即触发扫描(支持组织扫描和目标扫描两种模式) 立即触发扫描(支持组织扫描和目标扫描两种模式)
复用 ScanService 的逻辑,与 API 调用保持一致。 复用 ScanService 的逻辑,与 API 调用保持一致。
使用存储的 merged_configuration 而不是重新合并。 使用存储的 yaml_configuration 而不是重新合并。
""" """
from apps.scan.services.scan_service import ScanService from apps.scan.services.scan_service import ScanService
@@ -347,7 +399,7 @@ class ScheduledScanService:
targets=targets, targets=targets,
engine_ids=scheduled_scan.engine_ids, engine_ids=scheduled_scan.engine_ids,
engine_names=scheduled_scan.engine_names, engine_names=scheduled_scan.engine_names,
merged_configuration=scheduled_scan.merged_configuration, yaml_configuration=scheduled_scan.yaml_configuration,
scheduled_scan_name=scheduled_scan.name scheduled_scan_name=scheduled_scan.name
) )

View File

@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
from ..models import Scan, ScheduledScan from ..models import Scan, ScheduledScan
from ..serializers import ( from ..serializers import (
ScanSerializer, ScanHistorySerializer, QuickScanSerializer, ScanSerializer, ScanHistorySerializer, QuickScanSerializer,
ScheduledScanSerializer, CreateScheduledScanSerializer, InitiateScanSerializer, ScheduledScanSerializer, CreateScheduledScanSerializer,
UpdateScheduledScanSerializer, ToggleScheduledScanSerializer UpdateScheduledScanSerializer, ToggleScheduledScanSerializer
) )
from ..services.scan_service import ScanService from ..services.scan_service import ScanService
@@ -111,7 +111,7 @@ class ScanViewSet(viewsets.ModelViewSet):
快速扫描接口 快速扫描接口
功能: 功能:
1. 接收目标列表和引擎配置 1. 接收目标列表和 YAML 配置
2. 自动解析输入(支持 URL、域名、IP、CIDR 2. 自动解析输入(支持 URL、域名、IP、CIDR
3. 批量创建 Target、Website、Endpoint 资产 3. 批量创建 Target、Website、Endpoint 资产
4. 立即发起批量扫描 4. 立即发起批量扫描
@@ -119,7 +119,9 @@ class ScanViewSet(viewsets.ModelViewSet):
请求参数: 请求参数:
{ {
"targets": [{"name": "example.com"}, {"name": "https://example.com/api"}], "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) serializer.is_valid(raise_exception=True)
targets_data = serializer.validated_data['targets'] 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: try:
# 提取输入字符串列表 # 提取输入字符串列表
@@ -154,19 +158,13 @@ class ScanViewSet(viewsets.ModelViewSet):
status_code=status.HTTP_400_BAD_REQUEST status_code=status.HTTP_400_BAD_REQUEST
) )
# 2. 准备多引擎扫描 # 2. 直接使用前端传递的配置创建扫描
scan_service = ScanService() 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( created_scans = scan_service.create_scans(
targets=targets, targets=targets,
engine_ids=engine_ids, engine_ids=engine_ids,
engine_names=engine_names, 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 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: except ValidationError as e:
return error_response( return error_response(
@@ -228,48 +215,53 @@ class ScanViewSet(viewsets.ModelViewSet):
请求参数: 请求参数:
- organization_id: 组织ID (int, 可选) - organization_id: 组织ID (int, 可选)
- target_id: 目标ID (int, 可选) - target_id: 目标ID (int, 可选)
- configuration: YAML 配置字符串 (str, 必填)
- engine_ids: 扫描引擎ID列表 (list[int], 必填) - engine_ids: 扫描引擎ID列表 (list[int], 必填)
- engine_names: 引擎名称列表 (list[str], 必填)
注意: organization_id 和 target_id 二选一 注意: organization_id 和 target_id 二选一
返回: 返回:
- 扫描任务详情(单个或多个) - 扫描任务详情(单个或多个)
""" """
# 获取请求数据 # 使用 serializer 验证请求数据
organization_id = request.data.get('organization_id') serializer = InitiateScanSerializer(data=request.data)
target_id = request.data.get('target_id') serializer.is_valid(raise_exception=True)
engine_ids = request.data.get('engine_ids')
# 验证 engine_ids # 获取验证后的数据
if not engine_ids: organization_id = serializer.validated_data.get('organization_id')
return error_response( target_id = serializer.validated_data.get('target_id')
code=ErrorCodes.VALIDATION_ERROR, configuration = serializer.validated_data['configuration']
message='缺少必填参数: engine_ids', engine_ids = serializer.validated_data['engine_ids']
status_code=status.HTTP_400_BAD_REQUEST engine_names = serializer.validated_data['engine_names']
)
if not isinstance(engine_ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='engine_ids 必须是数组',
status_code=status.HTTP_400_BAD_REQUEST
)
try: try:
# 步骤1准备多引擎扫描所需的数据 # 获取目标列表
scan_service = ScanService() 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( created_scans = scan_service.create_scans(
targets=targets, targets=targets,
engine_ids=engine_ids, engine_ids=engine_ids,
engine_names=engine_names, 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 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: except ObjectDoesNotExist as e:
# 资源不存在错误(由 service 层抛出) # 资源不存在错误(由 service 层抛出)

View File

@@ -68,30 +68,22 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
data = serializer.validated_data data = serializer.validated_data
dto = ScheduledScanDTO( dto = ScheduledScanDTO(
name=data['name'], 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'), organization_id=data.get('organization_id'),
target_id=data.get('target_id'), target_id=data.get('target_id'),
cron_expression=data.get('cron_expression', '0 2 * * *'), cron_expression=data.get('cron_expression', '0 2 * * *'),
is_enabled=data.get('is_enabled', True), 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) response_serializer = ScheduledScanSerializer(scheduled_scan)
return success_response( return success_response(
data=response_serializer.data, data=response_serializer.data,
status_code=status.HTTP_201_CREATED 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: except ValidationError as e:
return error_response( return error_response(
code=ErrorCodes.VALIDATION_ERROR, code=ErrorCodes.VALIDATION_ERROR,

View File

@@ -219,6 +219,8 @@ REST_FRAMEWORK = {
# 允许所有来源(前后端分离项目,安全性由认证系统保障) # 允许所有来源(前后端分离项目,安全性由认证系统保障)
CORS_ALLOW_ALL_ORIGINS = os.getenv('CORS_ALLOW_ALL_ORIGINS', 'True').lower() == 'true' CORS_ALLOW_ALL_ORIGINS = os.getenv('CORS_ALLOW_ALL_ORIGINS', 'True').lower() == 'true'
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
# 暴露额外的响应头给前端Content-Disposition 用于文件下载获取文件名)
CORS_EXPOSE_HEADERS = ['Content-Disposition']
# ==================== CSRF 配置 ==================== # ==================== CSRF 配置 ====================
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://127.0.0.1:3000').split(',') CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://127.0.0.1:3000').split(',')

View File

@@ -8,7 +8,7 @@ services:
build: build:
context: ./postgres context: ./postgres
dockerfile: Dockerfile dockerfile: Dockerfile
image: ${DOCKER_USER:-yyhuni}/xingrin-postgres:15 image: ${DOCKER_USER:-yyhuni}/xingrin-postgres:${IMAGE_TAG:-dev}
restart: always restart: always
environment: environment:
POSTGRES_DB: ${DB_NAME} POSTGRES_DB: ${DB_NAME}

View File

@@ -14,7 +14,7 @@ services:
build: build:
context: ./postgres context: ./postgres
dockerfile: Dockerfile dockerfile: Dockerfile
image: ${DOCKER_USER:-yyhuni}/xingrin-postgres:15 image: ${DOCKER_USER:-yyhuni}/xingrin-postgres:${IMAGE_TAG:?IMAGE_TAG is required}
restart: always restart: always
environment: environment:
POSTGRES_DB: ${DB_NAME} POSTGRES_DB: ${DB_NAME}

View File

@@ -1,6 +1,5 @@
# 第一阶段:使用 Go 官方镜像编译工具 # 第一阶段:使用 Go 官方镜像编译工具
# 锁定 digest 避免上游更新导致缓存失效 FROM golang:1.24 AS go-builder
FROM golang:1.24@sha256:7e050c14ae9ca5ae56408a288336545b18632f51402ab0ec8e7be0e649a1fc42 AS go-builder
ENV GOPROXY=https://goproxy.cn,direct ENV GOPROXY=https://goproxy.cn,direct
# Naabu 需要 CGO 和 libpcap # 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 RUN go install github.com/hahwul/dalfox/v2@latest
# 第二阶段:运行时镜像 # 第二阶段:运行时镜像
# 锁定 digest 避免上游更新导致缓存失效 FROM ubuntu:24.04
FROM ubuntu:24.04@sha256:4fdf0125919d24aec972544669dcd7d6a26a8ad7e6561c73d5549bd6db258ac2
# 避免交互式提示 # 避免交互式提示
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import React, { useState, useMemo } from "react" import React, { useState, useMemo, useCallback } from "react"
import { Play, Settings2 } from "lucide-react" import { Play, Settings2 } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -13,11 +13,22 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } 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 { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { LoadingSpinner } from "@/components/loading-spinner" import { LoadingSpinner } from "@/components/loading-spinner"
import { cn } from "@/lib/utils" 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" import type { Organization } from "@/types/organization.types"
@@ -49,6 +60,13 @@ export function InitiateScanDialog({
const tCommon = useTranslations("common.actions") const tCommon = useTranslations("common.actions")
const [selectedEngineIds, setSelectedEngineIds] = useState<number[]>([]) const [selectedEngineIds, setSelectedEngineIds] = useState<number[]>([])
const [isSubmitting, setIsSubmitting] = useState(false) 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() const { data: engines, isLoading, error } = useEngines()
@@ -66,16 +84,67 @@ export function InitiateScanDialog({
return Array.from(allCaps) return Array.from(allCaps)
}, [selectedEngines]) }, [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) { if (checked) {
setSelectedEngineIds((prev) => [...prev, engineId]) newEngineIds = [...selectedEngineIds, engineId]
} else { } 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 () => { 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) { if (!organizationId && !targetId) {
toast.error(tToast("paramError"), { description: tToast("paramErrorDesc") }) toast.error(tToast("paramError"), { description: tToast("paramErrorDesc") })
return return
@@ -85,7 +154,9 @@ export function InitiateScanDialog({
const response = await initiateScan({ const response = await initiateScan({
organizationId, organizationId,
targetId, targetId,
configuration,
engineIds: selectedEngineIds, engineIds: selectedEngineIds,
engineNames: selectedEngines.map(e => e.name),
}) })
// 后端返回 201 说明成功创建扫描任务 // 后端返回 201 说明成功创建扫描任务
@@ -96,19 +167,14 @@ export function InitiateScanDialog({
onSuccess?.() onSuccess?.()
onOpenChange(false) onOpenChange(false)
setSelectedEngineIds([]) setSelectedEngineIds([])
setConfiguration("")
setIsConfigEdited(false)
} catch (err: unknown) { } catch (err: unknown) {
console.error("Failed to initiate scan:", err) console.error("Failed to initiate scan:", err)
// 处理配置冲突错误
const error = err as { response?: { data?: { error?: { code?: string; message?: string } } } } const error = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
if (error?.response?.data?.error?.code === 'CONFIG_CONFLICT') { toast.error(tToast("initiateScanFailed"), {
toast.error(tToast("configConflict"), { description: error?.response?.data?.error?.message || (err instanceof Error ? err.message : tToast("unknownError")),
description: error.response.data.error.message, })
})
} else {
toast.error(tToast("initiateScanFailed"), {
description: err instanceof Error ? err.message : tToast("unknownError"),
})
}
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -117,7 +183,11 @@ export function InitiateScanDialog({
const handleOpenChange = (newOpen: boolean) => { const handleOpenChange = (newOpen: boolean) => {
if (!isSubmitting) { if (!isSubmitting) {
onOpenChange(newOpen) onOpenChange(newOpen)
if (!newOpen) setSelectedEngineIds([]) if (!newOpen) {
setSelectedEngineIds([])
setConfiguration("")
setIsConfigEdited(false)
}
} }
} }
@@ -220,30 +290,49 @@ export function InitiateScanDialog({
{selectedEngines.length > 0 ? ( {selectedEngines.length > 0 ? (
<> <>
<div className="px-4 py-3 border-b bg-muted/30 shrink-0 min-w-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"> <div className="flex items-center gap-2">
{selectedCapabilities.map((capKey) => { <div className="flex flex-wrap gap-1.5 flex-1">
const config = CAPABILITY_CONFIG[capKey] {selectedCapabilities.map((capKey) => {
return ( const config = CAPABILITY_CONFIG[capKey]
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}> return (
{config?.label || capKey} <Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
</Badge> {config?.label || capKey}
) </Badge>
})} )
})}
</div>
{isConfigEdited && (
<Badge variant="outline" className="text-xs shrink-0">
{t("configEdited")}
</Badge>
)}
</div> </div>
</div> </div>
<div className="flex-1 flex flex-col overflow-hidden p-4 min-w-0"> <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"> <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"> <YamlEditor
{selectedEngines.map((e) => e.configuration || `# ${t("noConfig")}`).join("\n\n")} value={configuration}
</pre> onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground"> <div className="flex-1 flex flex-col overflow-hidden">
<div className="text-center"> <div className="px-4 py-3 border-b bg-muted/30 shrink-0">
<Settings2 className="h-10 w-10 mx-auto mb-3 opacity-30" /> <h3 className="text-sm font-medium">{t("configTitle")}</h3>
<p className="text-sm">{t("selectEngineHint")}</p> </div>
<div className="flex-1 flex flex-col overflow-hidden p-4">
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div> </div>
</div> </div>
)} )}
@@ -254,7 +343,7 @@ export function InitiateScanDialog({
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}> <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
{tCommon("cancel")} {tCommon("cancel")}
</Button> </Button>
<Button onClick={handleInitiate} disabled={!selectedEngineIds.length || isSubmitting}> <Button onClick={handleInitiate} disabled={selectedEngineIds.length === 0 || !configuration.trim() || !isYamlValid || isSubmitting}>
{isSubmitting ? ( {isSubmitting ? (
<> <>
<LoadingSpinner /> <LoadingSpinner />
@@ -269,6 +358,26 @@ export function InitiateScanDialog({
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </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> </Dialog>
) )
} }

View File

@@ -11,16 +11,27 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } 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 { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { LoadingSpinner } from "@/components/loading-spinner" import { LoadingSpinner } from "@/components/loading-spinner"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { Zap, Settings2, AlertCircle, ChevronRight, ChevronLeft, Target, Server } from "lucide-react" import { Zap, Settings2, AlertCircle, ChevronRight, ChevronLeft, Target, Server } from "lucide-react"
import { quickScan } from "@/services/scan.service" 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 { TargetValidator } from "@/lib/target-validator"
import { useEngines } from "@/hooks/use-engines" import { useEngines } from "@/hooks/use-engines"
@@ -37,6 +48,13 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
const [targetInput, setTargetInput] = React.useState("") const [targetInput, setTargetInput] = React.useState("")
const [selectedEngineIds, setSelectedEngineIds] = React.useState<number[]>([]) 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 { data: engines, isLoading, error } = useEngines()
const lineNumbersRef = React.useRef<HTMLDivElement | null>(null) const lineNumbersRef = React.useRef<HTMLDivElement | null>(null)
@@ -70,9 +88,19 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
return Array.from(allCaps) return Array.from(allCaps)
}, [selectedEngines]) }, [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 = () => { const resetForm = () => {
setTargetInput("") setTargetInput("")
setSelectedEngineIds([]) setSelectedEngineIds([])
setConfiguration("")
setIsConfigEdited(false)
setStep(1) setStep(1)
} }
@@ -81,16 +109,52 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
if (!isOpen) resetForm() if (!isOpen) resetForm()
} }
const handleEngineToggle = (engineId: number, checked: boolean) => { const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) { if (checked) {
setSelectedEngineIds((prev) => [...prev, engineId]) newEngineIds = [...selectedEngineIds, engineId]
} else { } 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 canProceedToStep2 = validInputs.length > 0 && !hasErrors
const canSubmit = selectedEngineIds.length > 0 const canSubmit = selectedEngineIds.length > 0 && configuration.trim().length > 0 && isYamlValid
const handleNext = () => { const handleNext = () => {
if (step === 1 && canProceedToStep2) setStep(2) if (step === 1 && canProceedToStep2) setStep(2)
@@ -118,6 +182,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
toast.error(t("toast.selectEngine")) toast.error(t("toast.selectEngine"))
return return
} }
if (!configuration.trim()) {
toast.error(t("toast.emptyConfig"))
return
}
const targets = validInputs.map(r => r.originalInput) const targets = validInputs.map(r => r.originalInput)
@@ -125,7 +193,9 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
try { try {
const response = await quickScan({ const response = await quickScan({
targets: targets.map(name => ({ name })), targets: targets.map(name => ({ name })),
configuration,
engineIds: selectedEngineIds, engineIds: selectedEngineIds,
engineNames: selectedEngines.map(e => e.name),
}) })
const { targetStats, scans, count } = response const { targetStats, scans, count } = response
@@ -139,13 +209,7 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
handleClose(false) handleClose(false)
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { error?: { code?: string; message?: string }; detail?: string } } } const err = error as { response?: { data?: { error?: { code?: string; message?: string }; detail?: string } } }
if (err?.response?.data?.error?.code === 'CONFIG_CONFLICT') { toast.error(err?.response?.data?.detail || err?.response?.data?.error?.message || t("toast.createFailed"))
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"))
}
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -338,6 +402,11 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
<h3 className="text-sm font-medium truncate"> <h3 className="text-sm font-medium truncate">
{selectedEngines.map((e) => e.name).join(", ")} {selectedEngines.map((e) => e.name).join(", ")}
</h3> </h3>
{isConfigEdited && (
<Badge variant="outline" className="ml-auto text-xs">
{t("configEdited")}
</Badge>
)}
</div> </div>
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-3"> <div className="flex-1 flex flex-col overflow-hidden p-4 gap-3">
{selectedCapabilities.length > 0 && ( {selectedCapabilities.length > 0 && (
@@ -353,17 +422,30 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
</div> </div>
)} )}
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0"> <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"> <YamlEditor
{selectedEngines.map((e) => e.configuration || `# ${t("noConfig")}`).join("\n\n")} value={configuration}
</pre> onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground"> <div className="flex-1 flex flex-col overflow-hidden">
<div className="text-center"> <div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
<Settings2 className="h-10 w-10 mx-auto mb-3 opacity-30" /> <Settings2 className="h-4 w-4 text-muted-foreground" />
<p className="text-sm">{t("selectEngineHint")}</p> <h3 className="text-sm font-medium">{t("configTitle")}</h3>
</div>
<div className="flex-1 flex flex-col overflow-hidden p-4">
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div> </div>
</div> </div>
)} )}
@@ -418,6 +500,26 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
</div> </div>
</DialogFooter> </DialogFooter>
</DialogContent> </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> </Dialog>
) )
} }

View File

@@ -195,7 +195,7 @@ export function ScanProgressDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-fit sm:min-w-[450px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<ScanStatusIcon status={data.status} /> <ScanStatusIcon status={data.status} />
@@ -209,9 +209,19 @@ export function ScanProgressDialog({
<span className="text-muted-foreground">{t("target")}</span> <span className="text-muted-foreground">{t("target")}</span>
<span className="font-medium">{data.targetName}</span> <span className="font-medium">{data.targetName}</span>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-start justify-between text-sm gap-4">
<span className="text-muted-foreground">{t("engine")}</span> <span className="text-muted-foreground shrink-0">{t("engine")}</span>
<Badge variant="secondary">{data.engineNames?.join(", ") || "-"}</Badge> <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> </div>
{data.startedAt && ( {data.startedAt && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">

View File

@@ -9,11 +9,22 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -43,6 +54,7 @@ import { useTargets } from "@/hooks/use-targets"
import { useEngines } from "@/hooks/use-engines" import { useEngines } from "@/hooks/use-engines"
import { useOrganizations } from "@/hooks/use-organizations" import { useOrganizations } from "@/hooks/use-organizations"
import { useTranslations, useLocale } from "next-intl" 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 { CreateScheduledScanRequest } from "@/types/scheduled-scan.types"
import type { ScanEngine } from "@/types/engine.types" import type { ScanEngine } from "@/types/engine.types"
import type { Target } from "@/types/target.types" import type { Target } from "@/types/target.types"
@@ -124,6 +136,13 @@ export function CreateScheduledScanDialog({
const [selectedOrgId, setSelectedOrgId] = React.useState<number | null>(null) const [selectedOrgId, setSelectedOrgId] = React.useState<number | null>(null)
const [selectedTargetId, setSelectedTargetId] = React.useState<number | null>(null) const [selectedTargetId, setSelectedTargetId] = React.useState<number | null>(null)
const [cronExpression, setCronExpression] = React.useState("0 2 * * *") 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(() => { React.useEffect(() => {
if (open) { if (open) {
@@ -143,6 +162,30 @@ export function CreateScheduledScanDialog({
const engines: ScanEngine[] = enginesData || [] const engines: ScanEngine[] = enginesData || []
const organizations: Organization[] = organizationsData?.organizations || [] 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 = () => { const resetForm = () => {
setName("") setName("")
setEngineIds([]) setEngineIds([])
@@ -150,15 +193,53 @@ export function CreateScheduledScanDialog({
setSelectedOrgId(null) setSelectedOrgId(null)
setSelectedTargetId(null) setSelectedTargetId(null)
setCronExpression("0 2 * * *") setCronExpression("0 2 * * *")
setConfiguration("")
setIsConfigEdited(false)
resetStep() resetStep()
} }
const handleEngineToggle = (engineId: number, checked: boolean) => { const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) { if (checked) {
setEngineIds((prev) => [...prev, engineId]) newEngineIds = [...engineIds, engineId]
} else { } 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) => { const handleOpenChange = (isOpen: boolean) => {
@@ -180,6 +261,8 @@ export function CreateScheduledScanDialog({
case 1: case 1:
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false } if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); 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 return true
case 2: case 2:
const parts = cronExpression.trim().split(/\s+/) const parts = cronExpression.trim().split(/\s+/)
@@ -193,6 +276,8 @@ export function CreateScheduledScanDialog({
case 1: case 1:
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false } if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); 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 return true
case 2: return true case 2: return true
case 3: case 3:
@@ -216,7 +301,9 @@ export function CreateScheduledScanDialog({
if (!validateCurrentStep()) return if (!validateCurrentStep()) return
const request: CreateScheduledScanRequest = { const request: CreateScheduledScanRequest = {
name: name.trim(), name: name.trim(),
configuration: configuration.trim(),
engineIds: engineIds, engineIds: engineIds,
engineNames: selectedEngines.map(e => e.name),
cronExpression: cronExpression.trim(), cronExpression: cronExpression.trim(),
} }
if (selectionMode === "organization" && selectedOrgId) { if (selectionMode === "organization" && selectedOrgId) {
@@ -306,7 +393,7 @@ export function CreateScheduledScanDialog({
{engineIds.length > 0 && ( {engineIds.length > 0 && (
<p className="text-xs text-muted-foreground">{t("form.selectedEngines", { count: engineIds.length })}</p> <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 ? ( {engines.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("form.noEngine")}</p> <p className="text-sm text-muted-foreground">{t("form.noEngine")}</p>
) : ( ) : (
@@ -333,6 +420,36 @@ export function CreateScheduledScanDialog({
</div> </div>
<p className="text-xs text-muted-foreground">{t("form.scanEngineDesc")}</p> <p className="text-xs text-muted-foreground">{t("form.scanEngineDesc")}</p>
</div> </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> </div>
)} )}
@@ -504,6 +621,26 @@ export function CreateScheduledScanDialog({
)} )}
</div> </div>
</DialogContent> </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> </Dialog>
) )
} }

View 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>
)
}

View File

@@ -80,3 +80,14 @@ export function parseEngineCapabilities(configuration: string): string[] {
return [] 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")
}

View File

@@ -175,6 +175,12 @@
"website": "Website", "website": "Website",
"description": "Description" "description": "Description"
}, },
"yamlEditor": {
"syntaxError": "Syntax Error",
"syntaxValid": "Syntax Valid",
"errorLocation": "Line {line}, Column {column}",
"loading": "Loading editor..."
},
"theme": { "theme": {
"switchToLight": "Switch to light mode", "switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode", "switchToDark": "Switch to dark mode",
@@ -749,8 +755,14 @@
"taskNameRequired": "Please enter task name", "taskNameRequired": "Please enter task name",
"scanEngine": "Scan Engine", "scanEngine": "Scan Engine",
"scanEnginePlaceholder": "Select 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", "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", "selectScanMode": "Select Scan Mode",
"organizationScan": "Organization Scan", "organizationScan": "Organization Scan",
"organizationScanDesc": "Select organization, dynamically fetch all targets at execution", "organizationScanDesc": "Select organization, dynamically fetch all targets at execution",
@@ -803,7 +815,14 @@
}, },
"toast": { "toast": {
"selectOrganization": "Please select an organization", "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": { "engine": {
@@ -1405,6 +1424,8 @@
"initiateScanFailed": "Failed to initiate scan", "initiateScanFailed": "Failed to initiate scan",
"noScansCreated": "No scan tasks were created", "noScansCreated": "No scan tasks were created",
"unknownError": "Unknown error", "unknownError": "Unknown error",
"noEngineSelected": "Please select at least one scan engine",
"emptyConfig": "Scan configuration cannot be empty",
"engineNameRequired": "Please enter engine name", "engineNameRequired": "Please enter engine name",
"configRequired": "Configuration content is required", "configRequired": "Configuration content is required",
"yamlSyntaxError": "YAML syntax error", "yamlSyntaxError": "YAML syntax error",
@@ -1741,6 +1762,7 @@
"noValidTarget": "Please enter at least one valid target", "noValidTarget": "Please enter at least one valid target",
"hasInvalidInputs": "{count} invalid inputs, please fix before continuing", "hasInvalidInputs": "{count} invalid inputs, please fix before continuing",
"selectEngine": "Please select a scan engine", "selectEngine": "Please select a scan engine",
"emptyConfig": "Scan configuration cannot be empty",
"getEnginesFailed": "Failed to get engine list", "getEnginesFailed": "Failed to get engine list",
"createFailed": "Failed to create scan task", "createFailed": "Failed to create scan task",
"createSuccess": "Created {count} scan tasks", "createSuccess": "Created {count} scan tasks",

View File

@@ -175,6 +175,12 @@
"website": "官网", "website": "官网",
"description": "描述" "description": "描述"
}, },
"yamlEditor": {
"syntaxError": "语法错误",
"syntaxValid": "语法正确",
"errorLocation": "第 {line} 行,第 {column} 列",
"loading": "加载编辑器..."
},
"theme": { "theme": {
"switchToLight": "切换到亮色模式", "switchToLight": "切换到亮色模式",
"switchToDark": "切换到暗色模式", "switchToDark": "切换到暗色模式",
@@ -749,8 +755,14 @@
"taskNameRequired": "请输入任务名称", "taskNameRequired": "请输入任务名称",
"scanEngine": "扫描引擎", "scanEngine": "扫描引擎",
"scanEnginePlaceholder": "选择扫描引擎", "scanEnginePlaceholder": "选择扫描引擎",
"scanEngineDesc": "选择要使用的扫描引擎配置", "scanEngineDesc": "选择引擎可快速填充配置,也可直接编辑配置",
"scanEngineRequired": "请选择扫描引擎", "scanEngineRequired": "请选择扫描引擎",
"configuration": "扫描配置",
"configurationPlaceholder": "请输入 YAML 格式的扫描配置...",
"configurationDesc": "YAML 格式的扫描配置,可选择引擎自动填充或手动编辑",
"configurationRequired": "请输入扫描配置",
"yamlInvalid": "YAML 配置格式错误,请检查语法",
"configEdited": "已编辑",
"selectScanMode": "选择扫描模式", "selectScanMode": "选择扫描模式",
"organizationScan": "组织扫描", "organizationScan": "组织扫描",
"organizationScanDesc": "选择组织,执行时动态获取其下所有目标", "organizationScanDesc": "选择组织,执行时动态获取其下所有目标",
@@ -803,7 +815,14 @@
}, },
"toast": { "toast": {
"selectOrganization": "请选择一个组织", "selectOrganization": "请选择一个组织",
"selectTarget": "请选择一个扫描目标" "selectTarget": "请选择一个扫描目标",
"configConflict": "配置冲突"
},
"overwriteConfirm": {
"title": "覆盖配置确认",
"description": "您已手动编辑了配置,切换引擎将覆盖当前配置。确定要继续吗?",
"cancel": "取消",
"confirm": "确定覆盖"
} }
}, },
"engine": { "engine": {
@@ -1405,6 +1424,8 @@
"initiateScanFailed": "发起扫描失败", "initiateScanFailed": "发起扫描失败",
"noScansCreated": "未创建任何扫描任务", "noScansCreated": "未创建任何扫描任务",
"unknownError": "未知错误", "unknownError": "未知错误",
"noEngineSelected": "请选择至少一个扫描引擎",
"emptyConfig": "扫描配置不能为空",
"engineNameRequired": "请输入引擎名称", "engineNameRequired": "请输入引擎名称",
"configRequired": "配置内容不能为空", "configRequired": "配置内容不能为空",
"yamlSyntaxError": "YAML 语法错误", "yamlSyntaxError": "YAML 语法错误",
@@ -1741,6 +1762,7 @@
"noValidTarget": "请输入至少一个有效目标", "noValidTarget": "请输入至少一个有效目标",
"hasInvalidInputs": "存在 {count} 个无效输入,请修正后继续", "hasInvalidInputs": "存在 {count} 个无效输入,请修正后继续",
"selectEngine": "请选择扫描引擎", "selectEngine": "请选择扫描引擎",
"emptyConfig": "扫描配置不能为空",
"getEnginesFailed": "获取引擎列表失败", "getEnginesFailed": "获取引擎列表失败",
"createFailed": "创建扫描任务失败", "createFailed": "创建扫描任务失败",
"createSuccess": "已创建 {count} 个扫描任务", "createSuccess": "已创建 {count} 个扫描任务",

View File

@@ -82,7 +82,9 @@ export interface GetScansResponse {
export interface InitiateScanRequest { export interface InitiateScanRequest {
organizationId?: number // Organization ID (choose one) organizationId?: number // Organization ID (choose one)
targetId?: number // Target ID (choose one) targetId?: number // Target ID (choose one)
configuration: string // YAML configuration string (required)
engineIds: number[] // Scan engine ID list (required) engineIds: number[] // Scan engine ID list (required)
engineNames: string[] // Engine name list (required)
} }
/** /**
@@ -90,7 +92,9 @@ export interface InitiateScanRequest {
*/ */
export interface QuickScanRequest { export interface QuickScanRequest {
targets: { name: string }[] // Target list targets: { name: string }[] // Target list
configuration: string // YAML configuration string (required)
engineIds: number[] // Scan engine ID list (required) engineIds: number[] // Scan engine ID list (required)
engineNames: string[] // Engine name list (required)
} }
/** /**

View File

@@ -31,7 +31,9 @@ export interface ScheduledScan {
// Create scheduled scan request (organizationId and targetId are mutually exclusive) // Create scheduled scan request (organizationId and targetId are mutually exclusive)
export interface CreateScheduledScanRequest { export interface CreateScheduledScanRequest {
name: string 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 organizationId?: number // Organization scan mode
targetId?: number // Target scan mode targetId?: number // Target scan mode
cronExpression: string // Cron expression, format: minute hour day month weekday 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) // Update scheduled scan request (organizationId and targetId are mutually exclusive)
export interface UpdateScheduledScanRequest { export interface UpdateScheduledScanRequest {
name?: string 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) organizationId?: number // Organization scan mode (clears targetId when set)
targetId?: number // Target scan mode (clears organizationId when set) targetId?: number // Target scan mode (clears organizationId when set)
cronExpression?: string cronExpression?: string