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

View File

@@ -13,14 +13,14 @@
<p align="center">
<a href="#-功能特性">功能特性</a> •
<a href="#-全局资产搜索">资产搜索</a> •
<a href="#-快速开始">快速开始</a> •
<a href="#-文档">文档</a> •
<a href="#-技术栈">技术栈</a> •
<a href="#-反馈与贡献">反馈与贡献</a>
</p>
<p align="center">
<sub>🔍 关键词: ASM | 攻击面管理 | 漏洞扫描 | 资产发现 | Bug Bounty | 渗透测试 | Nuclei | 子域名枚举 | EASM</sub>
<sub>🔍 关键词: ASM | 攻击面管理 | 漏洞扫描 | 资产发现 | 资产搜索 | Bug Bounty | 渗透测试 | Nuclei | 子域名枚举 | EASM</sub>
</p>
---
@@ -162,9 +162,34 @@ flowchart TB
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 消息推送
- **通知推送** - 实时企业微信tgdiscard消息推送服务
---
@@ -172,7 +197,7 @@ flowchart TB
### 环境要求
- **操作系统**: Ubuntu 20.04+ / Debian 11+ (推荐)
- **操作系统**: Ubuntu 20.04+ / Debian 11+
- **硬件**: 2核 4G 内存起步20GB+ 磁盘空间
### 一键安装
@@ -197,6 +222,7 @@ sudo ./install.sh --mirror
### 访问服务
- **Web 界面**: `https://ip:8083`
- **默认账号**: admin / admin首次登录后请修改密码
### 常用命令
@@ -216,13 +242,9 @@ sudo ./uninstall.sh
## 🤝 反馈与贡献
- 🐛 **如果发现 Bug** 可以点击右边链接进行提交 [Issue](https://github.com/yyhuni/xingrin/issues)
- 💡 **有新想法比如UI设计功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues)
- 💡 **发现 Bug有新想法比如UI设计功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues) 或者公众号私信
## 📧 联系
- 目前版本就我个人使用,可能会有很多边界问题
- 如有问题,建议,其他,优先提交[Issue](https://github.com/yyhuni/xingrin/issues),也可以直接给我的公众号发消息,我都会回复的
- 微信公众号: **塔罗安全学苑**
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">

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
logger = logging.getLogger(__name__)
class AssetConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.asset'
def ready(self):
# 导入所有模型以确保Django发现并注册
from . import models
# 启用 pg_trgm 扩展(用于文本模糊搜索索引)
# 用于已有数据库升级场景
self._ensure_pg_trgm_extension()
# 验证 pg_ivm 扩展是否可用(用于 IMMV 增量维护)
self._verify_pg_ivm_extension()
def _ensure_pg_trgm_extension(self):
"""
确保 pg_trgm 扩展已启用。
该扩展用于 response_body 和 response_headers 字段的 GIN 索引,
支持高效的文本模糊搜索。
"""
from django.db import connection
# 检查是否为 PostgreSQL 数据库
if connection.vendor != 'postgresql':
logger.debug("跳过 pg_trgm 扩展:当前数据库不是 PostgreSQL")
return
try:
with connection.cursor() as cursor:
cursor.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
logger.debug("pg_trgm 扩展已启用")
except Exception as e:
# 记录错误但不阻止应用启动
# 常见原因:权限不足(需要超级用户权限)
logger.warning(
"无法创建 pg_trgm 扩展: %s"
"这可能导致 response_body 和 response_headers 字段的 GIN 索引无法正常工作。"
"请手动执行: CREATE EXTENSION IF NOT EXISTS pg_trgm;",
str(e)
)
def _verify_pg_ivm_extension(self):
"""
验证 pg_ivm 扩展是否可用。
pg_ivm 用于 IMMV增量维护物化视图是系统必需的扩展。
如果不可用,将记录错误并退出。
"""
from django.db import connection
# 检查是否为 PostgreSQL 数据库
if connection.vendor != 'postgresql':
logger.debug("跳过 pg_ivm 验证:当前数据库不是 PostgreSQL")
return
# 跳过某些管理命令(如 migrate、makemigrations
import sys
if len(sys.argv) > 1 and sys.argv[1] in ('migrate', 'makemigrations', 'collectstatic', 'check'):
logger.debug("跳过 pg_ivm 验证:当前为管理命令")
return
try:
with connection.cursor() as cursor:
# 检查 pg_ivm 扩展是否已安装
cursor.execute("""
SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_ivm'
""")
count = cursor.fetchone()[0]
if count > 0:
logger.info("✓ pg_ivm 扩展已启用")
else:
# 尝试创建扩展
try:
cursor.execute("CREATE EXTENSION IF NOT EXISTS pg_ivm;")
logger.info("✓ pg_ivm 扩展已创建并启用")
except Exception as create_error:
logger.error(
"=" * 60 + "\n"
"错误: pg_ivm 扩展未安装\n"
"=" * 60 + "\n"
"pg_ivm 是系统必需的扩展,用于增量维护物化视图。\n\n"
"请在 PostgreSQL 服务器上安装 pg_ivm\n"
" curl -sSL https://raw.githubusercontent.com/yyhuni/xingrin/main/docker/scripts/install-pg-ivm.sh | sudo bash\n\n"
"或手动安装:\n"
" 1. apt install build-essential postgresql-server-dev-15 git\n"
" 2. git clone https://github.com/sraoss/pg_ivm.git && cd pg_ivm && make && make install\n"
" 3. 在 postgresql.conf 中添加: shared_preload_libraries = 'pg_ivm'\n"
" 4. 重启 PostgreSQL\n"
"=" * 60
)
# 在生产环境中退出,开发环境中仅警告
from django.conf import settings
if not settings.DEBUG:
sys.exit(1)
except Exception as e:
logger.error(f"pg_ivm 扩展验证失败: {e}")

View File

@@ -18,7 +18,13 @@ class Migration(migrations.Migration):
]
operations = [
# 1. 确保 pg_ivm 扩展已启用
# 1. 确保 pg_trgm 扩展已启用(用于文本模糊搜索索引)
migrations.RunSQL(
sql="CREATE EXTENSION IF NOT EXISTS pg_trgm;",
reverse_sql="-- pg_trgm extension kept for other uses"
),
# 2. 确保 pg_ivm 扩展已启用(用于 IMMV 增量维护)
migrations.RunSQL(
sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;",
reverse_sql="-- pg_ivm extension kept for other uses"

View File

@@ -11,7 +11,8 @@
import logging
import re
from typing import Optional, List, Dict, Any, Tuple, Literal
import uuid
from typing import Optional, List, Dict, Any, Tuple, Literal, Iterator
from django.db import connection
@@ -394,3 +395,49 @@ class AssetSearchService:
except Exception as e:
logger.error(f"统计查询失败: {e}")
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 json
import csv
from io import StringIO
from datetime import datetime
from urllib.parse import urlparse, urlunparse
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.request import Request
from django.http import StreamingHttpResponse
from django.db import connection
from django.db import connection, transaction
from django.utils.decorators import method_decorator
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
@@ -287,76 +286,41 @@ class AssetSearchExportView(APIView):
asset_type: 资产类型 ('website''endpoint',默认 'website')
Response:
CSV 文件流
"""
CSV 文件流(使用服务端游标,支持大数据量导出)
# 导出数量限制
MAX_EXPORT_ROWS = 10000
注意:使用 @transaction.non_atomic_requests 装饰器,
因为服务端游标不能在事务块内使用。
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.service = AssetSearchService()
def _parse_headers(self, headers_data) -> str:
"""解析响应头为字符串"""
if not headers_data:
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 内容的生成器"""
# 定义列
def _get_headers_and_formatters(self, asset_type: str):
"""获取 CSV 表头和格式化器"""
from apps.common.utils import format_datetime, format_list_field
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']
headers = ['URL', 'Host', 'Title', 'Status', 'Content-Type', 'Content-Length',
'Webserver', 'Location', 'Technologies', 'VHost', 'Created At']
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']
headers = ['URL', 'Host', 'Title', 'Status', 'Content-Type', 'Content-Length',
'Webserver', 'Location', 'Technologies', 'GF Patterns', 'VHost', 'Created At']
# 写入 BOM 和表头
output = StringIO()
writer = csv.writer(output)
formatters = {
'created_at': format_datetime,
'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
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)
return headers, formatters
@method_decorator(transaction.non_atomic_requests)
def get(self, request: Request):
"""导出搜索结果为 CSV"""
"""导出搜索结果为 CSV(流式导出,无数量限制)"""
from apps.common.utils import generate_csv_rows
# 获取搜索查询
query = request.query_params.get('q', '').strip()
@@ -376,23 +340,28 @@ class AssetSearchExportView(APIView):
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取搜索结果(限制数量
results = self.service.search(query, asset_type, limit=self.MAX_EXPORT_ROWS)
if not results:
# 检查是否有结果(快速检查,避免空导出
total = self.service.count(query, asset_type)
if total == 0:
return error_response(
code=ErrorCodes.NOT_FOUND,
message='No results to export',
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')
filename = f'search_{asset_type}_{timestamp}.csv'
# 返回流式响应
response = StreamingHttpResponse(
self._generate_csv(results, asset_type),
generate_csv_rows(data_iterator, headers, formatters),
content_type='text/csv; charset=utf-8'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'

View File

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

View File

@@ -57,7 +57,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(primary_key=True, serialize=False)),
('engine_ids', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, help_text='引擎 ID 列表', size=None)),
('engine_names', models.JSONField(default=list, help_text='引擎名称列表,如 ["引擎A", "引擎B"]')),
('merged_configuration', models.TextField(default='', help_text='合并后的 YAML 配置')),
('yaml_configuration', models.TextField(default='', help_text='YAML 格式的扫描配置')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='任务创建时间')),
('stopped_at', models.DateTimeField(blank=True, help_text='扫描结束时间', null=True)),
('status', models.CharField(choices=[('cancelled', '已取消'), ('completed', '已完成'), ('failed', '失败'), ('initiated', '初始化'), ('running', '运行中')], db_index=True, default='initiated', help_text='任务状态', max_length=20)),
@@ -97,7 +97,7 @@ class Migration(migrations.Migration):
('name', models.CharField(help_text='任务名称', max_length=200)),
('engine_ids', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, help_text='引擎 ID 列表', size=None)),
('engine_names', models.JSONField(default=list, help_text='引擎名称列表,如 ["引擎A", "引擎B"]')),
('merged_configuration', models.TextField(default='', help_text='合并后的 YAML 配置')),
('yaml_configuration', models.TextField(default='', help_text='YAML 格式的扫描配置')),
('cron_expression', models.CharField(default='0 2 * * *', help_text='Cron 表达式,格式:分 时 日 月 周', max_length=100)),
('is_enabled', models.BooleanField(db_index=True, default=True, help_text='是否启用')),
('run_count', models.IntegerField(default=0, help_text='已执行次数')),

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,41 @@ from django.db.models import Count
from .models import Scan, ScheduledScan
# ==================== 通用验证 Mixin ====================
class ScanConfigValidationMixin:
"""扫描配置验证 Mixin提供通用的验证方法"""
def validate_configuration(self, value):
"""验证 YAML 配置格式"""
import yaml
if not value or not value.strip():
raise serializers.ValidationError("configuration 不能为空")
try:
yaml.safe_load(value)
except yaml.YAMLError as e:
raise serializers.ValidationError(f"无效的 YAML 格式: {str(e)}")
return value
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if not value:
raise serializers.ValidationError("engine_ids 不能为空,请至少选择一个扫描引擎")
return value
def validate_engine_names(self, value):
"""验证引擎名称列表"""
if not value:
raise serializers.ValidationError("engine_names 不能为空")
return value
# ==================== 扫描任务序列化器 ====================
class ScanSerializer(serializers.ModelSerializer):
"""扫描任务序列化器"""
target_name = serializers.SerializerMethodField()
@@ -82,12 +117,12 @@ class ScanHistorySerializer(serializers.ModelSerializer):
return summary
class QuickScanSerializer(serializers.Serializer):
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""
快速扫描序列化器
功能:
- 接收目标列表和引擎配置
- 接收目标列表和 YAML 配置
- 自动创建/获取目标
- 立即发起扫描
"""
@@ -101,11 +136,24 @@ class QuickScanSerializer(serializers.Serializer):
help_text='目标列表,每个目标包含 name 字段'
)
# 扫描引擎 ID 列表
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填,用于记录和显示)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
help_text='使用的扫描引擎 ID 列表 (必填)'
help_text='使用的扫描引擎 ID 列表必填'
)
# 引擎名称列表(必填,用于记录和显示)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
def validate_targets(self, value):
@@ -127,12 +175,6 @@ class QuickScanSerializer(serializers.Serializer):
raise serializers.ValidationError(f"{idx + 1} 个目标的 name 不能为空")
return value
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if not value:
raise serializers.ValidationError("engine_ids 不能为空")
return value
# ==================== 定时扫描序列化器 ====================
@@ -171,7 +213,7 @@ class ScheduledScanSerializer(serializers.ModelSerializer):
return 'organization' if obj.organization_id else 'target'
class CreateScheduledScanSerializer(serializers.Serializer):
class CreateScheduledScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""创建定时扫描任务序列化器
扫描模式(二选一):
@@ -180,9 +222,25 @@ class CreateScheduledScanSerializer(serializers.Serializer):
"""
name = serializers.CharField(max_length=200, help_text='任务名称')
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填,用于记录和显示)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text='扫描引擎 ID 列表'
required=True,
help_text='扫描引擎 ID 列表(必填)'
)
# 引擎名称列表(必填,用于记录和显示)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
# 组织扫描模式
@@ -206,11 +264,61 @@ class CreateScheduledScanSerializer(serializers.Serializer):
)
is_enabled = serializers.BooleanField(default=True, help_text='是否立即启用')
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if not value:
raise serializers.ValidationError("engine_ids 不能为空")
return value
def validate(self, data):
"""验证 organization_id 和 target_id 互斥"""
organization_id = data.get('organization_id')
target_id = data.get('target_id')
if not organization_id and not target_id:
raise serializers.ValidationError('必须提供 organization_id 或 target_id 其中之一')
if organization_id and target_id:
raise serializers.ValidationError('organization_id 和 target_id 只能提供其中之一')
return data
class InitiateScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""发起扫描任务序列化器
扫描模式(二选一):
- 组织扫描:提供 organization_id扫描组织下所有目标
- 目标扫描:提供 target_id扫描单个目标
"""
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
help_text='扫描引擎 ID 列表(必填)'
)
# 引擎名称列表(必填)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
# 组织扫描模式
organization_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='组织 ID组织扫描模式'
)
# 目标扫描模式
target_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='目标 ID目标扫描模式'
)
def validate(self, data):
"""验证 organization_id 和 target_id 互斥"""

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ class ScheduledScanService:
def create(self, dto: ScheduledScanDTO) -> ScheduledScan:
"""
创建定时扫描任务
创建定时扫描任务(使用引擎 ID 合并配置)
流程:
1. 验证参数
@@ -88,7 +88,7 @@ class ScheduledScanService:
# 设置 DTO 的合并配置和引擎名称
dto.engine_names = engine_names
dto.merged_configuration = merged_configuration
dto.yaml_configuration = merged_configuration
# 3. 创建数据库记录
scheduled_scan = self.repo.create(dto)
@@ -107,12 +107,49 @@ class ScheduledScanService:
return scheduled_scan
def _validate_create_dto(self, dto: ScheduledScanDTO) -> None:
"""验证创建 DTO"""
from apps.targets.repositories import DjangoOrganizationRepository
def create_with_configuration(self, dto: ScheduledScanDTO) -> ScheduledScan:
"""
创建定时扫描任务(直接使用前端传递的配置)
if not dto.name:
raise ValidationError('任务名称不能为空')
流程:
1. 验证参数
2. 直接使用 dto.yaml_configuration
3. 创建数据库记录
4. 计算并设置 next_run_time
Args:
dto: 定时扫描 DTO必须包含 yaml_configuration
Returns:
创建的 ScheduledScan 对象
Raises:
ValidationError: 参数验证失败
"""
# 1. 验证参数
self._validate_create_dto_with_configuration(dto)
# 2. 创建数据库记录(直接使用 dto 中的配置)
scheduled_scan = self.repo.create(dto)
# 3. 如果有 cron 表达式且已启用,计算下次执行时间
if scheduled_scan.cron_expression and scheduled_scan.is_enabled:
next_run_time = self._calculate_next_run_time(scheduled_scan)
if next_run_time:
self.repo.update_next_run_time(scheduled_scan.id, next_run_time)
scheduled_scan.next_run_time = next_run_time
logger.info(
"创建定时扫描任务 - ID: %s, 名称: %s, 下次执行: %s",
scheduled_scan.id, scheduled_scan.name, scheduled_scan.next_run_time
)
return scheduled_scan
def _validate_create_dto(self, dto: ScheduledScanDTO) -> None:
"""验证创建 DTO使用引擎 ID"""
# 基础验证
self._validate_base_dto(dto)
if not dto.engine_ids:
raise ValidationError('必须选择扫描引擎')
@@ -121,6 +158,21 @@ class ScheduledScanService:
for engine_id in dto.engine_ids:
if not self.engine_repo.get_by_id(engine_id):
raise ValidationError(f'扫描引擎 ID {engine_id} 不存在')
def _validate_create_dto_with_configuration(self, dto: ScheduledScanDTO) -> None:
"""验证创建 DTO使用前端传递的配置"""
# 基础验证
self._validate_base_dto(dto)
if not dto.yaml_configuration:
raise ValidationError('配置不能为空')
def _validate_base_dto(self, dto: ScheduledScanDTO) -> None:
"""验证 DTO 的基础字段(公共逻辑)"""
from apps.targets.repositories import DjangoOrganizationRepository
if not dto.name:
raise ValidationError('任务名称不能为空')
# 验证扫描模式organization_id 和 target_id 互斥)
if not dto.organization_id and not dto.target_id:
@@ -178,7 +230,7 @@ class ScheduledScanService:
merged_configuration = merge_engine_configs(engines)
dto.engine_names = engine_names
dto.merged_configuration = merged_configuration
dto.yaml_configuration = merged_configuration
# 更新数据库记录
scheduled_scan = self.repo.update(scheduled_scan_id, dto)
@@ -329,7 +381,7 @@ class ScheduledScanService:
立即触发扫描(支持组织扫描和目标扫描两种模式)
复用 ScanService 的逻辑,与 API 调用保持一致。
使用存储的 merged_configuration 而不是重新合并。
使用存储的 yaml_configuration 而不是重新合并。
"""
from apps.scan.services.scan_service import ScanService
@@ -347,7 +399,7 @@ class ScheduledScanService:
targets=targets,
engine_ids=scheduled_scan.engine_ids,
engine_names=scheduled_scan.engine_names,
merged_configuration=scheduled_scan.merged_configuration,
yaml_configuration=scheduled_scan.yaml_configuration,
scheduled_scan_name=scheduled_scan.name
)

View File

@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
from ..models import Scan, ScheduledScan
from ..serializers import (
ScanSerializer, ScanHistorySerializer, QuickScanSerializer,
ScheduledScanSerializer, CreateScheduledScanSerializer,
InitiateScanSerializer, ScheduledScanSerializer, CreateScheduledScanSerializer,
UpdateScheduledScanSerializer, ToggleScheduledScanSerializer
)
from ..services.scan_service import ScanService
@@ -111,7 +111,7 @@ class ScanViewSet(viewsets.ModelViewSet):
快速扫描接口
功能:
1. 接收目标列表和引擎配置
1. 接收目标列表和 YAML 配置
2. 自动解析输入(支持 URL、域名、IP、CIDR
3. 批量创建 Target、Website、Endpoint 资产
4. 立即发起批量扫描
@@ -119,7 +119,9 @@ class ScanViewSet(viewsets.ModelViewSet):
请求参数:
{
"targets": [{"name": "example.com"}, {"name": "https://example.com/api"}],
"engine_ids": [1, 2]
"configuration": "subdomain_discovery:\n enabled: true\n ...",
"engine_ids": [1, 2], // 可选,用于记录
"engine_names": ["引擎A", "引擎B"] // 可选,用于记录
}
支持的输入格式:
@@ -134,7 +136,9 @@ class ScanViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
targets_data = serializer.validated_data['targets']
engine_ids = serializer.validated_data.get('engine_ids')
configuration = serializer.validated_data['configuration']
engine_ids = serializer.validated_data.get('engine_ids', [])
engine_names = serializer.validated_data.get('engine_names', [])
try:
# 提取输入字符串列表
@@ -154,19 +158,13 @@ class ScanViewSet(viewsets.ModelViewSet):
status_code=status.HTTP_400_BAD_REQUEST
)
# 2. 准备多引擎扫描
# 2. 直接使用前端传递的配置创建扫描
scan_service = ScanService()
_, merged_configuration, engine_names, engine_ids = scan_service.prepare_initiate_scan_multi_engine(
target_id=targets[0].id, # 使用第一个目标来验证引擎
engine_ids=engine_ids
)
# 3. 批量发起扫描
created_scans = scan_service.create_scans(
targets=targets,
engine_ids=engine_ids,
engine_names=engine_names,
merged_configuration=merged_configuration
yaml_configuration=configuration
)
# 检查是否成功创建扫描任务
@@ -195,17 +193,6 @@ class ScanViewSet(viewsets.ModelViewSet):
},
status_code=status.HTTP_201_CREATED
)
except ConfigConflictError as e:
return error_response(
code='CONFIG_CONFLICT',
message=str(e),
details=[
{'key': k, 'engines': [e1, e2]}
for k, e1, e2 in e.conflicts
],
status_code=status.HTTP_400_BAD_REQUEST
)
except ValidationError as e:
return error_response(
@@ -228,48 +215,53 @@ class ScanViewSet(viewsets.ModelViewSet):
请求参数:
- organization_id: 组织ID (int, 可选)
- target_id: 目标ID (int, 可选)
- configuration: YAML 配置字符串 (str, 必填)
- engine_ids: 扫描引擎ID列表 (list[int], 必填)
- engine_names: 引擎名称列表 (list[str], 必填)
注意: organization_id 和 target_id 二选一
返回:
- 扫描任务详情(单个或多个)
"""
# 获取请求数据
organization_id = request.data.get('organization_id')
target_id = request.data.get('target_id')
engine_ids = request.data.get('engine_ids')
# 使用 serializer 验证请求数据
serializer = InitiateScanSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 验证 engine_ids
if not engine_ids:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='缺少必填参数: engine_ids',
status_code=status.HTTP_400_BAD_REQUEST
)
if not isinstance(engine_ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='engine_ids 必须是数组',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取验证后的数据
organization_id = serializer.validated_data.get('organization_id')
target_id = serializer.validated_data.get('target_id')
configuration = serializer.validated_data['configuration']
engine_ids = serializer.validated_data['engine_ids']
engine_names = serializer.validated_data['engine_names']
try:
# 步骤1准备多引擎扫描所需的数据
# 获取目标列表
scan_service = ScanService()
targets, merged_configuration, engine_names, engine_ids = scan_service.prepare_initiate_scan_multi_engine(
organization_id=organization_id,
target_id=target_id,
engine_ids=engine_ids
)
# 步骤2批量创建扫描记录并分发扫描任务
if organization_id:
from apps.targets.repositories import DjangoOrganizationRepository
org_repo = DjangoOrganizationRepository()
organization = org_repo.get_by_id(organization_id)
if not organization:
raise ObjectDoesNotExist(f'Organization ID {organization_id} 不存在')
targets = org_repo.get_targets(organization_id)
if not targets:
raise ValidationError(f'组织 ID {organization_id} 下没有目标')
else:
from apps.targets.repositories import DjangoTargetRepository
target_repo = DjangoTargetRepository()
target = target_repo.get_by_id(target_id)
if not target:
raise ObjectDoesNotExist(f'Target ID {target_id} 不存在')
targets = [target]
# 直接使用前端传递的配置创建扫描
created_scans = scan_service.create_scans(
targets=targets,
engine_ids=engine_ids,
engine_names=engine_names,
merged_configuration=merged_configuration
yaml_configuration=configuration
)
# 检查是否成功创建扫描任务
@@ -290,17 +282,6 @@ class ScanViewSet(viewsets.ModelViewSet):
},
status_code=status.HTTP_201_CREATED
)
except ConfigConflictError as e:
return error_response(
code='CONFIG_CONFLICT',
message=str(e),
details=[
{'key': k, 'engines': [e1, e2]}
for k, e1, e2 in e.conflicts
],
status_code=status.HTTP_400_BAD_REQUEST
)
except ObjectDoesNotExist as e:
# 资源不存在错误(由 service 层抛出)

View File

@@ -68,30 +68,22 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
data = serializer.validated_data
dto = ScheduledScanDTO(
name=data['name'],
engine_ids=data['engine_ids'],
engine_ids=data.get('engine_ids', []),
engine_names=data.get('engine_names', []),
yaml_configuration=data['configuration'],
organization_id=data.get('organization_id'),
target_id=data.get('target_id'),
cron_expression=data.get('cron_expression', '0 2 * * *'),
is_enabled=data.get('is_enabled', True),
)
scheduled_scan = self.service.create(dto)
scheduled_scan = self.service.create_with_configuration(dto)
response_serializer = ScheduledScanSerializer(scheduled_scan)
return success_response(
data=response_serializer.data,
status_code=status.HTTP_201_CREATED
)
except ConfigConflictError as e:
return error_response(
code='CONFIG_CONFLICT',
message=str(e),
details=[
{'key': k, 'engines': [e1, e2]}
for k, e1, e2 in e.conflicts
],
status_code=status.HTTP_400_BAD_REQUEST
)
except ValidationError as e:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,16 +11,27 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { LoadingSpinner } from "@/components/loading-spinner"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { Zap, Settings2, AlertCircle, ChevronRight, ChevronLeft, Target, Server } from "lucide-react"
import { quickScan } from "@/services/scan.service"
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities } from "@/lib/engine-config"
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
import { TargetValidator } from "@/lib/target-validator"
import { useEngines } from "@/hooks/use-engines"
@@ -37,6 +48,13 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
const [targetInput, setTargetInput] = React.useState("")
const [selectedEngineIds, setSelectedEngineIds] = React.useState<number[]>([])
// Configuration state management
const [configuration, setConfiguration] = React.useState("")
const [isConfigEdited, setIsConfigEdited] = React.useState(false)
const [isYamlValid, setIsYamlValid] = React.useState(true)
const [showOverwriteConfirm, setShowOverwriteConfirm] = React.useState(false)
const [pendingEngineChange, setPendingEngineChange] = React.useState<{ engineId: number; checked: boolean } | null>(null)
const { data: engines, isLoading, error } = useEngines()
const lineNumbersRef = React.useRef<HTMLDivElement | null>(null)
@@ -70,9 +88,19 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
return Array.from(allCaps)
}, [selectedEngines])
// Update configuration when engines change (if not manually edited)
const updateConfigurationFromEngines = React.useCallback((engineIds: number[]) => {
if (!engines) return
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
setConfiguration(mergedConfig)
}, [engines])
const resetForm = () => {
setTargetInput("")
setSelectedEngineIds([])
setConfiguration("")
setIsConfigEdited(false)
setStep(1)
}
@@ -81,16 +109,52 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
if (!isOpen) resetForm()
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) {
setSelectedEngineIds((prev) => [...prev, engineId])
newEngineIds = [...selectedEngineIds, engineId]
} else {
setSelectedEngineIds((prev) => prev.filter((id) => id !== engineId))
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
}
setSelectedEngineIds(newEngineIds)
updateConfigurationFromEngines(newEngineIds)
setIsConfigEdited(false)
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
if (isConfigEdited) {
// User has edited config, show confirmation
setPendingEngineChange({ engineId, checked })
setShowOverwriteConfirm(true)
} else {
applyEngineChange(engineId, checked)
}
}
const handleOverwriteConfirm = () => {
if (pendingEngineChange) {
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
}
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleOverwriteCancel = () => {
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleConfigurationChange = (value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
}
const handleYamlValidationChange = (isValid: boolean) => {
setIsYamlValid(isValid)
}
const canProceedToStep2 = validInputs.length > 0 && !hasErrors
const canSubmit = selectedEngineIds.length > 0
const canSubmit = selectedEngineIds.length > 0 && configuration.trim().length > 0 && isYamlValid
const handleNext = () => {
if (step === 1 && canProceedToStep2) setStep(2)
@@ -118,6 +182,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
toast.error(t("toast.selectEngine"))
return
}
if (!configuration.trim()) {
toast.error(t("toast.emptyConfig"))
return
}
const targets = validInputs.map(r => r.originalInput)
@@ -125,7 +193,9 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
try {
const response = await quickScan({
targets: targets.map(name => ({ name })),
configuration,
engineIds: selectedEngineIds,
engineNames: selectedEngines.map(e => e.name),
})
const { targetStats, scans, count } = response
@@ -139,13 +209,7 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
handleClose(false)
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: { code?: string; message?: string }; detail?: string } } }
if (err?.response?.data?.error?.code === 'CONFIG_CONFLICT') {
toast.error(t("toast.configConflict"), {
description: err.response.data.error.message,
})
} else {
toast.error(err?.response?.data?.detail || err?.response?.data?.error?.message || t("toast.createFailed"))
}
toast.error(err?.response?.data?.detail || err?.response?.data?.error?.message || t("toast.createFailed"))
} finally {
setIsSubmitting(false)
}
@@ -338,6 +402,11 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
<h3 className="text-sm font-medium truncate">
{selectedEngines.map((e) => e.name).join(", ")}
</h3>
{isConfigEdited && (
<Badge variant="outline" className="ml-auto text-xs">
{t("configEdited")}
</Badge>
)}
</div>
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-3">
{selectedCapabilities.length > 0 && (
@@ -353,17 +422,30 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
</div>
)}
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
<pre className="h-full p-3 text-xs font-mono overflow-auto whitespace-pre-wrap break-all">
{selectedEngines.map((e) => e.configuration || `# ${t("noConfig")}`).join("\n\n")}
</pre>
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Settings2 className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">{t("selectEngineHint")}</p>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{t("configTitle")}</h3>
</div>
<div className="flex-1 flex flex-col overflow-hidden p-4">
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div>
</div>
)}
@@ -418,6 +500,26 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
</div>
</DialogFooter>
</DialogContent>
{/* Overwrite confirmation dialog */}
<AlertDialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("overwriteConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("overwriteConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleOverwriteCancel}>
{t("overwriteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={handleOverwriteConfirm}>
{t("overwriteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}

View File

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

View File

@@ -9,11 +9,22 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { YamlEditor } from "@/components/ui/yaml-editor"
import {
Command,
CommandEmpty,
@@ -43,6 +54,7 @@ import { useTargets } from "@/hooks/use-targets"
import { useEngines } from "@/hooks/use-engines"
import { useOrganizations } from "@/hooks/use-organizations"
import { useTranslations, useLocale } from "next-intl"
import { mergeEngineConfigurations, CAPABILITY_CONFIG, parseEngineCapabilities } from "@/lib/engine-config"
import type { CreateScheduledScanRequest } from "@/types/scheduled-scan.types"
import type { ScanEngine } from "@/types/engine.types"
import type { Target } from "@/types/target.types"
@@ -124,6 +136,13 @@ export function CreateScheduledScanDialog({
const [selectedOrgId, setSelectedOrgId] = React.useState<number | null>(null)
const [selectedTargetId, setSelectedTargetId] = React.useState<number | null>(null)
const [cronExpression, setCronExpression] = React.useState("0 2 * * *")
// Configuration state management
const [configuration, setConfiguration] = React.useState("")
const [isConfigEdited, setIsConfigEdited] = React.useState(false)
const [isYamlValid, setIsYamlValid] = React.useState(true)
const [showOverwriteConfirm, setShowOverwriteConfirm] = React.useState(false)
const [pendingEngineChange, setPendingEngineChange] = React.useState<{ engineId: number; checked: boolean } | null>(null)
React.useEffect(() => {
if (open) {
@@ -143,6 +162,30 @@ export function CreateScheduledScanDialog({
const engines: ScanEngine[] = enginesData || []
const organizations: Organization[] = organizationsData?.organizations || []
// Get selected engines for display
const selectedEngines = React.useMemo(() => {
if (!engineIds.length || !engines.length) return []
return engines.filter(e => engineIds.includes(e.id))
}, [engineIds, engines])
// Get selected capabilities for display
const selectedCapabilities = React.useMemo(() => {
if (!selectedEngines.length) return []
const allCaps = new Set<string>()
selectedEngines.forEach((engine) => {
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
})
return Array.from(allCaps)
}, [selectedEngines])
// Update configuration when engines change (if not manually edited)
const updateConfigurationFromEngines = React.useCallback((newEngineIds: number[]) => {
if (!engines.length) return
const selectedEngs = engines.filter(e => newEngineIds.includes(e.id))
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
setConfiguration(mergedConfig)
}, [engines])
const resetForm = () => {
setName("")
setEngineIds([])
@@ -150,15 +193,53 @@ export function CreateScheduledScanDialog({
setSelectedOrgId(null)
setSelectedTargetId(null)
setCronExpression("0 2 * * *")
setConfiguration("")
setIsConfigEdited(false)
resetStep()
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) {
setEngineIds((prev) => [...prev, engineId])
newEngineIds = [...engineIds, engineId]
} else {
setEngineIds((prev) => prev.filter((id) => id !== engineId))
newEngineIds = engineIds.filter((id) => id !== engineId)
}
setEngineIds(newEngineIds)
updateConfigurationFromEngines(newEngineIds)
setIsConfigEdited(false)
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
if (isConfigEdited) {
// User has edited config, show confirmation
setPendingEngineChange({ engineId, checked })
setShowOverwriteConfirm(true)
} else {
applyEngineChange(engineId, checked)
}
}
const handleOverwriteConfirm = () => {
if (pendingEngineChange) {
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
}
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleOverwriteCancel = () => {
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleConfigurationChange = (value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
}
const handleYamlValidationChange = (isValid: boolean) => {
setIsYamlValid(isValid)
}
const handleOpenChange = (isOpen: boolean) => {
@@ -180,6 +261,8 @@ export function CreateScheduledScanDialog({
case 1:
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
return true
case 2:
const parts = cronExpression.trim().split(/\s+/)
@@ -193,6 +276,8 @@ export function CreateScheduledScanDialog({
case 1:
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
return true
case 2: return true
case 3:
@@ -216,7 +301,9 @@ export function CreateScheduledScanDialog({
if (!validateCurrentStep()) return
const request: CreateScheduledScanRequest = {
name: name.trim(),
configuration: configuration.trim(),
engineIds: engineIds,
engineNames: selectedEngines.map(e => e.name),
cronExpression: cronExpression.trim(),
}
if (selectionMode === "organization" && selectedOrgId) {
@@ -306,7 +393,7 @@ export function CreateScheduledScanDialog({
{engineIds.length > 0 && (
<p className="text-xs text-muted-foreground">{t("form.selectedEngines", { count: engineIds.length })}</p>
)}
<div className="border rounded-md p-3 max-h-[200px] overflow-y-auto space-y-2">
<div className="border rounded-md p-3 max-h-[150px] overflow-y-auto space-y-2">
{engines.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("form.noEngine")}</p>
) : (
@@ -333,6 +420,36 @@ export function CreateScheduledScanDialog({
</div>
<p className="text-xs text-muted-foreground">{t("form.scanEngineDesc")}</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("form.configuration")} *</Label>
{isConfigEdited && (
<Badge variant="outline" className="text-xs">
{t("form.configEdited")}
</Badge>
)}
</div>
{selectedCapabilities.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedCapabilities.map((capKey) => {
const config = CAPABILITY_CONFIG[capKey]
return (
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
{config?.label || capKey}
</Badge>
)
})}
</div>
)}
<div className="border rounded-md overflow-hidden h-[180px]">
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
onValidationChange={handleYamlValidationChange}
/>
</div>
<p className="text-xs text-muted-foreground">{t("form.configurationDesc")}</p>
</div>
</div>
)}
@@ -504,6 +621,26 @@ export function CreateScheduledScanDialog({
)}
</div>
</DialogContent>
{/* Overwrite confirmation dialog */}
<AlertDialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("overwriteConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("overwriteConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleOverwriteCancel}>
{t("overwriteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={handleOverwriteConfirm}>
{t("overwriteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}

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 []
}
}
/**
* 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",
"description": "Description"
},
"yamlEditor": {
"syntaxError": "Syntax Error",
"syntaxValid": "Syntax Valid",
"errorLocation": "Line {line}, Column {column}",
"loading": "Loading editor..."
},
"theme": {
"switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode",
@@ -749,8 +755,14 @@
"taskNameRequired": "Please enter task name",
"scanEngine": "Scan Engine",
"scanEnginePlaceholder": "Select scan engine",
"scanEngineDesc": "Select the scan engine configuration to use",
"scanEngineDesc": "Select engine to auto-fill configuration, or edit directly",
"scanEngineRequired": "Please select a scan engine",
"configuration": "Scan Configuration",
"configurationPlaceholder": "Enter YAML scan configuration...",
"configurationDesc": "YAML format scan configuration, select engine to auto-fill or edit manually",
"configurationRequired": "Please enter scan configuration",
"yamlInvalid": "Invalid YAML configuration, please check syntax",
"configEdited": "Edited",
"selectScanMode": "Select Scan Mode",
"organizationScan": "Organization Scan",
"organizationScanDesc": "Select organization, dynamically fetch all targets at execution",
@@ -803,7 +815,14 @@
},
"toast": {
"selectOrganization": "Please select an organization",
"selectTarget": "Please select a scan target"
"selectTarget": "Please select a scan target",
"configConflict": "Configuration conflict"
},
"overwriteConfirm": {
"title": "Overwrite Configuration",
"description": "You have manually edited the configuration. Changing engines will overwrite your changes. Do you want to continue?",
"cancel": "Cancel",
"confirm": "Overwrite"
}
},
"engine": {
@@ -1405,6 +1424,8 @@
"initiateScanFailed": "Failed to initiate scan",
"noScansCreated": "No scan tasks were created",
"unknownError": "Unknown error",
"noEngineSelected": "Please select at least one scan engine",
"emptyConfig": "Scan configuration cannot be empty",
"engineNameRequired": "Please enter engine name",
"configRequired": "Configuration content is required",
"yamlSyntaxError": "YAML syntax error",
@@ -1741,6 +1762,7 @@
"noValidTarget": "Please enter at least one valid target",
"hasInvalidInputs": "{count} invalid inputs, please fix before continuing",
"selectEngine": "Please select a scan engine",
"emptyConfig": "Scan configuration cannot be empty",
"getEnginesFailed": "Failed to get engine list",
"createFailed": "Failed to create scan task",
"createSuccess": "Created {count} scan tasks",

View File

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

View File

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

View File

@@ -31,7 +31,9 @@ export interface ScheduledScan {
// Create scheduled scan request (organizationId and targetId are mutually exclusive)
export interface CreateScheduledScanRequest {
name: string
engineIds: number[] // Engine ID list
configuration: string // YAML configuration string (required)
engineIds: number[] // Engine ID list (required)
engineNames: string[] // Engine name list (required)
organizationId?: number // Organization scan mode
targetId?: number // Target scan mode
cronExpression: string // Cron expression, format: minute hour day month weekday
@@ -41,7 +43,9 @@ export interface CreateScheduledScanRequest {
// Update scheduled scan request (organizationId and targetId are mutually exclusive)
export interface UpdateScheduledScanRequest {
name?: string
engineIds?: number[] // Engine ID list
configuration?: string // YAML configuration string
engineIds?: number[] // Engine ID list (optional, for reference)
engineNames?: string[] // Engine name list (optional, for reference)
organizationId?: number // Organization scan mode (clears targetId when set)
targetId?: number // Target scan mode (clears organizationId when set)
cronExpression?: string