Compare commits

...

6 Commits

Author SHA1 Message Date
yyhuni
d1ec9b7f27 feat(settings): add global blacklist management page and UI integration
- Add new global blacklist settings page with pattern management interface
- Create useGlobalBlacklist and useUpdateGlobalBlacklist React Query hooks for data fetching and mutations
- Implement global-blacklist.service.ts with API integration for blacklist operations
- Add Global Blacklist navigation item to app sidebar with Ban icon
- Add internationalization support for blacklist UI with English and Chinese translations
- Include pattern matching rules documentation (domain wildcards, keywords, IP addresses, CIDR ranges)
- Add loading states, error handling, and success/error toast notifications
- Implement textarea input with change tracking and save button state management
2026-01-06 11:50:31 +08:00
yyhuni
2a3d9b4446 feat(target): add initiate scan button and improve overview layout
- Add "Initiate Scan" button to target overview header with Play icon
- Implement InitiateScanDialog component integration for quick scan initiation
- Improve scheduled scans card layout with flexbox for better vertical spacing
- Reduce displayed scheduled scans from 3 to 2 items for better UI balance
- Enhance vulnerability statistics card styling with proper flex layout
- Add state management for scan dialog open/close functionality
- Update i18n translations (en.json, zh.json) with "initiateScan" label
- Refactor target info section to accommodate new action button with justify-between layout
- Improve empty state centering in scheduled scans card using flex layout
2026-01-06 11:10:47 +08:00
yyhuni
9b63203b5a refactor(migrations,frontend,backend): reorganize app structure and enhance target management UI
- Consolidate common migrations into dedicated common app module
- Remove asset search materialized view migration (0002) and simplify migration structure
- Reorganize target detail page with new overview and settings sub-routes
- Add target overview component displaying key asset information
- Add target settings component for configuration management
- Enhance scan history UI with improved data table and column definitions
- Update scheduled scan dialog with better form handling
- Refactor target service with improved API integration
- Update scan hooks (use-scans, use-scheduled-scans) with better state management
- Add internationalization strings for new target management features
- Update Docker initialization and startup scripts for new app structure
- Bump Django to 5.2.7 and update dependencies in requirements.txt
- Add WeChat group contact information to README
- Improve UI tabs component with better accessibility and styling
2026-01-06 10:42:38 +08:00
yyhuni
4c1282e9bb 完成黑名单后端逻辑 2026-01-05 23:26:50 +08:00
yyhuni
ba3a9b709d feat(system-logs): enhance ANSI log viewer with log level colorization
- Add LOG_LEVEL_COLORS configuration mapping for DEBUG, INFO, WARNING, WARN, ERROR, and CRITICAL levels
- Implement hasAnsiCodes() function to detect presence of ANSI escape sequences in log content
- Add colorizeLogContent() function to parse plain text logs and apply color styling based on log levels
- Support dual-mode log parsing: ANSI color codes and plain text log level detection
- Rename converter to ansiConverter for clarity and consistency
- Change newline handling from true to false for manual line break control
- Apply color-coded styling to timestamps (gray), log levels (level-specific colors), and messages
- Add bold font-weight styling for CRITICAL level logs for better visibility
2026-01-05 16:27:31 +08:00
github-actions[bot]
283b28b46a chore: bump version to v1.3.15-dev 2026-01-05 02:05:29 +00:00
66 changed files with 2614 additions and 585 deletions

View File

@@ -254,6 +254,7 @@ sudo ./uninstall.sh
## 📧 联系
- 微信公众号: **塔罗安全学苑**
- 微信群去公众号底下的菜单,有个交流群,点击就可以看到了,链接过期可以私信我拉你
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">

View File

@@ -1 +1 @@
v1.3.14
v1.3.15-dev

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2026-01-02 04:45
# Generated by Django 5.2.7 on 2026-01-06 00:55
import django.contrib.postgres.fields
import django.contrib.postgres.indexes

View File

@@ -1,196 +0,0 @@
"""
创建资产搜索 IMMV增量维护物化视图
使用 pg_ivm 扩展创建 IMMV数据变更时自动增量更新无需手动刷新。
包含:
1. asset_search_view - Website 搜索视图
2. endpoint_search_view - Endpoint 搜索视图
重要限制:
⚠️ pg_ivm 不支持数组类型字段ArrayField因为其使用 anyarray 伪类型进行比较时,
PostgreSQL 无法确定空数组的元素类型,导致错误:
"cannot determine element type of \"anyarray\" argument"
因此,所有 ArrayField 字段tech, matched_gf_patterns 等)已从 IMMV 中移除,
搜索时通过 JOIN 原表获取。
如需添加新的数组字段,请:
1. 不要将其包含在 IMMV 视图中
2. 在搜索服务中通过 JOIN 原表获取
"""
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('asset', '0001_initial'),
]
operations = [
# 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"
),
# ==================== Website IMMV ====================
# 2. 创建 asset_search_view IMMV
# ⚠️ 注意:不包含 w.tech 数组字段pg_ivm 不支持 ArrayField
# 数组字段通过 search_service.py 中 JOIN website 表获取
migrations.RunSQL(
sql="""
SELECT pgivm.create_immv('asset_search_view', $$
SELECT
w.id,
w.url,
w.host,
w.title,
w.status_code,
w.response_headers,
w.response_body,
w.content_type,
w.content_length,
w.webserver,
w.location,
w.vhost,
w.created_at,
w.target_id
FROM website w
$$);
""",
reverse_sql="SELECT pgivm.drop_immv('asset_search_view');"
),
# 3. 创建 asset_search_view 索引
migrations.RunSQL(
sql="""
-- 唯一索引
CREATE UNIQUE INDEX IF NOT EXISTS asset_search_view_id_idx
ON asset_search_view (id);
-- host 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_host_trgm_idx
ON asset_search_view USING gin (host gin_trgm_ops);
-- title 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_title_trgm_idx
ON asset_search_view USING gin (title gin_trgm_ops);
-- url 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_url_trgm_idx
ON asset_search_view USING gin (url gin_trgm_ops);
-- response_headers 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_headers_trgm_idx
ON asset_search_view USING gin (response_headers gin_trgm_ops);
-- response_body 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_body_trgm_idx
ON asset_search_view USING gin (response_body gin_trgm_ops);
-- status_code 索引
CREATE INDEX IF NOT EXISTS asset_search_view_status_idx
ON asset_search_view (status_code);
-- created_at 排序索引
CREATE INDEX IF NOT EXISTS asset_search_view_created_idx
ON asset_search_view (created_at DESC);
""",
reverse_sql="""
DROP INDEX IF EXISTS asset_search_view_id_idx;
DROP INDEX IF EXISTS asset_search_view_host_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_title_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_url_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_headers_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_body_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_status_idx;
DROP INDEX IF EXISTS asset_search_view_created_idx;
"""
),
# ==================== Endpoint IMMV ====================
# 4. 创建 endpoint_search_view IMMV
# ⚠️ 注意:不包含 e.tech 和 e.matched_gf_patterns 数组字段pg_ivm 不支持 ArrayField
# 数组字段通过 search_service.py 中 JOIN endpoint 表获取
migrations.RunSQL(
sql="""
SELECT pgivm.create_immv('endpoint_search_view', $$
SELECT
e.id,
e.url,
e.host,
e.title,
e.status_code,
e.response_headers,
e.response_body,
e.content_type,
e.content_length,
e.webserver,
e.location,
e.vhost,
e.created_at,
e.target_id
FROM endpoint e
$$);
""",
reverse_sql="SELECT pgivm.drop_immv('endpoint_search_view');"
),
# 5. 创建 endpoint_search_view 索引
migrations.RunSQL(
sql="""
-- 唯一索引
CREATE UNIQUE INDEX IF NOT EXISTS endpoint_search_view_id_idx
ON endpoint_search_view (id);
-- host 模糊搜索索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_host_trgm_idx
ON endpoint_search_view USING gin (host gin_trgm_ops);
-- title 模糊搜索索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_title_trgm_idx
ON endpoint_search_view USING gin (title gin_trgm_ops);
-- url 模糊搜索索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_url_trgm_idx
ON endpoint_search_view USING gin (url gin_trgm_ops);
-- response_headers 模糊搜索索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_headers_trgm_idx
ON endpoint_search_view USING gin (response_headers gin_trgm_ops);
-- response_body 模糊搜索索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_body_trgm_idx
ON endpoint_search_view USING gin (response_body gin_trgm_ops);
-- status_code 索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_status_idx
ON endpoint_search_view (status_code);
-- created_at 排序索引
CREATE INDEX IF NOT EXISTS endpoint_search_view_created_idx
ON endpoint_search_view (created_at DESC);
""",
reverse_sql="""
DROP INDEX IF EXISTS endpoint_search_view_id_idx;
DROP INDEX IF EXISTS endpoint_search_view_host_trgm_idx;
DROP INDEX IF EXISTS endpoint_search_view_title_trgm_idx;
DROP INDEX IF EXISTS endpoint_search_view_url_trgm_idx;
DROP INDEX IF EXISTS endpoint_search_view_headers_trgm_idx;
DROP INDEX IF EXISTS endpoint_search_view_body_trgm_idx;
DROP INDEX IF EXISTS endpoint_search_view_status_idx;
DROP INDEX IF EXISTS endpoint_search_view_created_idx;
"""
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.7 on 2026-01-06 00:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('targets', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='BlacklistRule',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('pattern', models.CharField(help_text='规则模式,如 *.gov, 10.0.0.0/8, 192.168.1.1', max_length=255)),
('rule_type', models.CharField(choices=[('domain', '域名'), ('ip', 'IP地址'), ('cidr', 'CIDR范围'), ('keyword', '关键词')], help_text='规则类型domain, ip, cidr', max_length=20)),
('scope', models.CharField(choices=[('global', '全局规则'), ('target', 'Target规则')], db_index=True, help_text='作用域global 或 target', max_length=20)),
('description', models.CharField(blank=True, default='', help_text='规则描述', max_length=500)),
('created_at', models.DateTimeField(auto_now_add=True)),
('target', models.ForeignKey(blank=True, help_text='关联的 Target仅 scope=target 时有值)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='blacklist_rules', to='targets.target')),
],
options={
'db_table': 'blacklist_rule',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['scope', 'rule_type'], name='blacklist_r_scope_6ff77f_idx'), models.Index(fields=['target', 'scope'], name='blacklist_r_target__191441_idx')],
'constraints': [models.UniqueConstraint(fields=('pattern', 'scope', 'target'), name='unique_blacklist_rule')],
},
),
]

View File

@@ -0,0 +1,4 @@
"""Common models"""
from apps.common.models.blacklist import BlacklistRule
__all__ = ['BlacklistRule']

View File

@@ -0,0 +1,71 @@
"""黑名单规则模型"""
from django.db import models
class BlacklistRule(models.Model):
"""黑名单规则模型
用于存储黑名单过滤规则支持域名、IP、CIDR 三种类型。
支持两层作用域:全局规则和 Target 级规则。
"""
class RuleType(models.TextChoices):
DOMAIN = 'domain', '域名'
IP = 'ip', 'IP地址'
CIDR = 'cidr', 'CIDR范围'
KEYWORD = 'keyword', '关键词'
class Scope(models.TextChoices):
GLOBAL = 'global', '全局规则'
TARGET = 'target', 'Target规则'
id = models.AutoField(primary_key=True)
pattern = models.CharField(
max_length=255,
help_text='规则模式,如 *.gov, 10.0.0.0/8, 192.168.1.1'
)
rule_type = models.CharField(
max_length=20,
choices=RuleType.choices,
help_text='规则类型domain, ip, cidr'
)
scope = models.CharField(
max_length=20,
choices=Scope.choices,
db_index=True,
help_text='作用域global 或 target'
)
target = models.ForeignKey(
'targets.Target',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='blacklist_rules',
help_text='关联的 Target仅 scope=target 时有值)'
)
description = models.CharField(
max_length=500,
blank=True,
default='',
help_text='规则描述'
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'blacklist_rule'
indexes = [
models.Index(fields=['scope', 'rule_type']),
models.Index(fields=['target', 'scope']),
]
constraints = [
models.UniqueConstraint(
fields=['pattern', 'scope', 'target'],
name='unique_blacklist_rule'
),
]
ordering = ['-created_at']
def __str__(self):
if self.scope == self.Scope.TARGET and self.target:
return f"[{self.scope}:{self.target_id}] {self.pattern}"
return f"[{self.scope}] {self.pattern}"

View File

@@ -0,0 +1,12 @@
"""Common serializers"""
from .blacklist_serializers import (
BlacklistRuleSerializer,
GlobalBlacklistRuleSerializer,
TargetBlacklistRuleSerializer,
)
__all__ = [
'BlacklistRuleSerializer',
'GlobalBlacklistRuleSerializer',
'TargetBlacklistRuleSerializer',
]

View File

@@ -0,0 +1,68 @@
"""黑名单规则序列化器"""
from rest_framework import serializers
from apps.common.models import BlacklistRule
from apps.common.utils import detect_rule_type
class BlacklistRuleSerializer(serializers.ModelSerializer):
"""黑名单规则序列化器"""
class Meta:
model = BlacklistRule
fields = [
'id',
'pattern',
'rule_type',
'scope',
'target',
'description',
'created_at',
]
read_only_fields = ['id', 'rule_type', 'created_at']
def validate_pattern(self, value):
"""验证规则模式"""
if not value or not value.strip():
raise serializers.ValidationError("规则模式不能为空")
return value.strip()
def create(self, validated_data):
"""创建规则时自动识别规则类型"""
pattern = validated_data.get('pattern', '')
validated_data['rule_type'] = detect_rule_type(pattern)
return super().create(validated_data)
def update(self, instance, validated_data):
"""更新规则时重新识别规则类型"""
if 'pattern' in validated_data:
pattern = validated_data['pattern']
validated_data['rule_type'] = detect_rule_type(pattern)
return super().update(instance, validated_data)
class GlobalBlacklistRuleSerializer(BlacklistRuleSerializer):
"""全局黑名单规则序列化器"""
class Meta(BlacklistRuleSerializer.Meta):
fields = ['id', 'pattern', 'rule_type', 'description', 'created_at']
read_only_fields = ['id', 'rule_type', 'created_at']
def create(self, validated_data):
"""创建全局规则"""
validated_data['scope'] = BlacklistRule.Scope.GLOBAL
validated_data['target'] = None
return super().create(validated_data)
class TargetBlacklistRuleSerializer(BlacklistRuleSerializer):
"""Target 黑名单规则序列化器"""
class Meta(BlacklistRuleSerializer.Meta):
fields = ['id', 'pattern', 'rule_type', 'description', 'created_at']
read_only_fields = ['id', 'rule_type', 'created_at']
def create(self, validated_data):
"""创建 Target 规则target_id 由 view 设置)"""
validated_data['scope'] = BlacklistRule.Scope.TARGET
return super().create(validated_data)

View File

@@ -3,13 +3,16 @@
提供系统级别的公共服务,包括:
- SystemLogService: 系统日志读取服务
- BlacklistService: 黑名单过滤服务
注意FilterService 已移至 apps.common.utils.filter_utils
推荐使用: from apps.common.utils.filter_utils import apply_filters
"""
from .system_log_service import SystemLogService
from .blacklist_service import BlacklistService
__all__ = [
'SystemLogService',
'BlacklistService',
]

View File

@@ -0,0 +1,176 @@
"""
黑名单规则管理服务
负责黑名单规则的 CRUD 操作(数据库层面)。
过滤逻辑请使用 apps.common.utils.BlacklistFilter。
架构说明:
- Model: BlacklistRule (apps.common.models.blacklist)
- Service: BlacklistService (本文件) - 规则 CRUD
- Utils: BlacklistFilter (apps.common.utils.blacklist_filter) - 过滤逻辑
- View: GlobalBlacklistView, TargetViewSet.blacklist
"""
import logging
from typing import List, Dict, Any, Optional
from django.db.models import QuerySet
from apps.common.utils import detect_rule_type
logger = logging.getLogger(__name__)
def _normalize_patterns(patterns: List[str]) -> List[str]:
"""
规范化规则列表:去重 + 过滤空行
Args:
patterns: 原始规则列表
Returns:
List[str]: 去重后的规则列表(保持顺序)
"""
return list(dict.fromkeys(filter(None, (p.strip() for p in patterns))))
class BlacklistService:
"""
黑名单规则管理服务
只负责规则的 CRUD 操作,不包含过滤逻辑。
过滤逻辑请使用 BlacklistFilter 工具类。
"""
def get_global_rules(self) -> QuerySet:
"""
获取全局黑名单规则列表
Returns:
QuerySet: 全局规则查询集
"""
from apps.common.models import BlacklistRule
return BlacklistRule.objects.filter(scope=BlacklistRule.Scope.GLOBAL)
def get_target_rules(self, target_id: int) -> QuerySet:
"""
获取 Target 级黑名单规则列表
Args:
target_id: Target ID
Returns:
QuerySet: Target 级规则查询集
"""
from apps.common.models import BlacklistRule
return BlacklistRule.objects.filter(
scope=BlacklistRule.Scope.TARGET,
target_id=target_id
)
def get_rules(self, target_id: Optional[int] = None) -> List:
"""
获取黑名单规则(全局 + Target 级)
Args:
target_id: Target ID用于加载 Target 级规则
Returns:
List[BlacklistRule]: 规则列表
"""
from apps.common.models import BlacklistRule
# 加载全局规则
rules = list(BlacklistRule.objects.filter(scope=BlacklistRule.Scope.GLOBAL))
# 加载 Target 级规则
if target_id:
target_rules = BlacklistRule.objects.filter(
scope=BlacklistRule.Scope.TARGET,
target_id=target_id
)
rules.extend(target_rules)
return rules
def replace_global_rules(self, patterns: List[str]) -> Dict[str, Any]:
"""
全量替换全局黑名单规则PUT 语义)
Args:
patterns: 新的规则模式列表
Returns:
Dict: {'count': int} 最终规则数量
"""
from apps.common.models import BlacklistRule
count = self._replace_rules(
patterns=patterns,
scope=BlacklistRule.Scope.GLOBAL,
target=None
)
logger.info("全量替换全局黑名单规则: %d", count)
return {'count': count}
def replace_target_rules(self, target, patterns: List[str]) -> Dict[str, Any]:
"""
全量替换 Target 级黑名单规则PUT 语义)
Args:
target: Target 对象
patterns: 新的规则模式列表
Returns:
Dict: {'count': int} 最终规则数量
"""
from apps.common.models import BlacklistRule
count = self._replace_rules(
patterns=patterns,
scope=BlacklistRule.Scope.TARGET,
target=target
)
logger.info("全量替换 Target 黑名单规则: %d 条 (Target: %s)", count, target.name)
return {'count': count}
def _replace_rules(self, patterns: List[str], scope: str, target=None) -> int:
"""
内部方法:全量替换规则
Args:
patterns: 规则模式列表
scope: 规则作用域 (GLOBAL/TARGET)
target: Target 对象(仅 TARGET 作用域需要)
Returns:
int: 最终规则数量
"""
from apps.common.models import BlacklistRule
from django.db import transaction
patterns = _normalize_patterns(patterns)
with transaction.atomic():
# 1. 删除旧规则
delete_filter = {'scope': scope}
if target:
delete_filter['target'] = target
BlacklistRule.objects.filter(**delete_filter).delete()
# 2. 创建新规则
if patterns:
rules = [
BlacklistRule(
pattern=pattern,
rule_type=detect_rule_type(pattern),
scope=scope,
target=target
)
for pattern in patterns
]
BlacklistRule.objects.bulk_create(rules)
return len(patterns)

View File

@@ -2,13 +2,19 @@
通用模块 URL 配置
路由说明:
- /api/health/ 健康检查接口(无需认证)
- /api/auth/* 认证相关接口(登录、登出、用户信息)
- /api/system/* 系统管理接口(日志查看等)
- /api/health/ 健康检查接口(无需认证)
- /api/auth/* 认证相关接口(登录、登出、用户信息)
- /api/system/* 系统管理接口(日志查看等)
- /api/blacklist/* 黑名单管理接口
"""
from django.urls import path
from .views import LoginView, LogoutView, MeView, ChangePasswordView, SystemLogsView, SystemLogFilesView, HealthCheckView
from .views import (
LoginView, LogoutView, MeView, ChangePasswordView,
SystemLogsView, SystemLogFilesView, HealthCheckView,
GlobalBlacklistView,
)
urlpatterns = [
# 健康检查(无需认证)
@@ -23,4 +29,7 @@ urlpatterns = [
# 系统管理
path('system/logs/', SystemLogsView.as_view(), name='system-logs'),
path('system/logs/files/', SystemLogFilesView.as_view(), name='system-log-files'),
# 黑名单管理PUT 全量替换模式)
path('blacklist/rules/', GlobalBlacklistView.as_view(), name='blacklist-rules'),
]

View File

@@ -14,6 +14,11 @@ from .csv_utils import (
create_csv_export_response,
UTF8_BOM,
)
from .blacklist_filter import (
BlacklistFilter,
detect_rule_type,
extract_host,
)
__all__ = [
'deduplicate_for_bulk',
@@ -27,4 +32,7 @@ __all__ = [
'format_datetime',
'create_csv_export_response',
'UTF8_BOM',
'BlacklistFilter',
'detect_rule_type',
'extract_host',
]

View File

@@ -0,0 +1,246 @@
"""
黑名单过滤工具
提供域名、IP、CIDR、关键词的黑名单匹配功能。
纯工具类,不涉及数据库操作。
支持的规则类型:
1. 域名精确匹配: example.com
- 规则: example.com
- 匹配: example.com
- 不匹配: sub.example.com, other.com
2. 域名后缀匹配: *.example.com
- 规则: *.example.com
- 匹配: sub.example.com, a.b.example.com, example.com
- 不匹配: other.com, example.com.cn
3. 关键词匹配: *cdn*
- 规则: *cdn*
- 匹配: cdn.example.com, a.cdn.b.com, mycdn123.com
- 不匹配: example.com (不包含 cdn)
4. IP 精确匹配: 192.168.1.1
- 规则: 192.168.1.1
- 匹配: 192.168.1.1
- 不匹配: 192.168.1.2
5. CIDR 范围匹配: 192.168.0.0/24
- 规则: 192.168.0.0/24
- 匹配: 192.168.0.1, 192.168.0.255
- 不匹配: 192.168.1.1
使用方式:
from apps.common.utils import BlacklistFilter
# 创建过滤器(传入规则列表)
rules = BlacklistRule.objects.filter(...)
filter = BlacklistFilter(rules)
# 检查单个目标
if filter.is_allowed('http://example.com'):
process(url)
# 流式处理
for url in urls:
if filter.is_allowed(url):
process(url)
"""
import ipaddress
import logging
from typing import List, Optional
from urllib.parse import urlparse
from apps.common.validators import is_valid_ip, validate_cidr
logger = logging.getLogger(__name__)
def detect_rule_type(pattern: str) -> str:
"""
自动识别规则类型
支持的模式:
- 域名精确匹配: example.com
- 域名后缀匹配: *.example.com
- 关键词匹配: *cdn* (匹配包含 cdn 的域名)
- IP 精确匹配: 192.168.1.1
- CIDR 范围: 192.168.0.0/24
Args:
pattern: 规则模式字符串
Returns:
str: 规则类型 ('domain', 'ip', 'cidr', 'keyword')
"""
if not pattern:
return 'domain'
pattern = pattern.strip()
# 检查关键词模式: *keyword* (前后都有星号,中间无点)
if pattern.startswith('*') and pattern.endswith('*') and len(pattern) > 2:
keyword = pattern[1:-1]
# 关键词中不能有点(否则可能是域名模式)
if '.' not in keyword:
return 'keyword'
# 检查 CIDR包含 /
if '/' in pattern:
try:
validate_cidr(pattern)
return 'cidr'
except ValueError:
pass
# 检查 IP去掉通配符前缀后验证
clean_pattern = pattern.lstrip('*').lstrip('.')
if is_valid_ip(clean_pattern):
return 'ip'
# 默认为域名
return 'domain'
def extract_host(target: str) -> str:
"""
从目标字符串中提取主机名
支持:
- 纯域名example.com
- 纯 IP192.168.1.1
- URLhttp://example.com/path
Args:
target: 目标字符串
Returns:
str: 提取的主机名
"""
if not target:
return ''
target = target.strip()
# 如果是 URL提取 hostname
if '://' in target:
try:
parsed = urlparse(target)
return parsed.hostname or target
except Exception:
return target
return target
class BlacklistFilter:
"""
黑名单过滤器
预编译规则,提供高效的匹配功能。
"""
def __init__(self, rules: List):
"""
初始化过滤器
Args:
rules: BlacklistRule 对象列表
"""
from apps.common.models import BlacklistRule
# 预解析:按类型分类 + CIDR 预编译
self._domain_rules = [] # (pattern, is_wildcard, suffix)
self._ip_rules = set() # 精确 IP 用 setO(1) 查找
self._cidr_rules = [] # (pattern, network_obj)
self._keyword_rules = [] # 关键词列表(小写)
# 去重:跨 scope 可能有重复规则
seen_patterns = set()
for rule in rules:
if rule.pattern in seen_patterns:
continue
seen_patterns.add(rule.pattern)
if rule.rule_type == BlacklistRule.RuleType.DOMAIN:
pattern = rule.pattern.lower()
if pattern.startswith('*.'):
self._domain_rules.append((pattern, True, pattern[1:]))
else:
self._domain_rules.append((pattern, False, None))
elif rule.rule_type == BlacklistRule.RuleType.IP:
self._ip_rules.add(rule.pattern)
elif rule.rule_type == BlacklistRule.RuleType.CIDR:
try:
network = ipaddress.ip_network(rule.pattern, strict=False)
self._cidr_rules.append((rule.pattern, network))
except ValueError:
pass
elif rule.rule_type == BlacklistRule.RuleType.KEYWORD:
# *cdn* -> cdn
keyword = rule.pattern[1:-1].lower()
self._keyword_rules.append(keyword)
def is_allowed(self, target: str) -> bool:
"""
检查目标是否通过过滤
Args:
target: 要检查的目标(域名/IP/URL
Returns:
bool: True 表示通过不在黑名单False 表示被过滤
"""
if not target:
return True
host = extract_host(target)
if not host:
return True
# 先判断输入类型,再走对应分支
if is_valid_ip(host):
return self._check_ip_rules(host)
else:
return self._check_domain_rules(host)
def _check_domain_rules(self, host: str) -> bool:
"""检查域名规则(精确匹配 + 后缀匹配 + 关键词匹配)"""
host_lower = host.lower()
# 1. 域名规则(精确 + 后缀)
for pattern, is_wildcard, suffix in self._domain_rules:
if is_wildcard:
if host_lower.endswith(suffix) or host_lower == pattern[2:]:
return False
else:
if host_lower == pattern:
return False
# 2. 关键词匹配(字符串 in 操作O(n*m)
for keyword in self._keyword_rules:
if keyword in host_lower:
return False
return True
def _check_ip_rules(self, host: str) -> bool:
"""检查 IP 规则(精确匹配 + CIDR"""
# 1. IP 精确匹配O(1)
if host in self._ip_rules:
return False
# 2. CIDR 匹配
if self._cidr_rules:
try:
ip_obj = ipaddress.ip_address(host)
for _, network in self._cidr_rules:
if ip_obj in network:
return False
except ValueError:
pass
return True

View File

@@ -5,14 +5,17 @@
- 健康检查视图Docker 健康检查
- 认证相关视图:登录、登出、用户信息、修改密码
- 系统日志视图:实时日志查看
- 黑名单视图:全局黑名单规则管理
"""
from .health_views import HealthCheckView
from .auth_views import LoginView, LogoutView, MeView, ChangePasswordView
from .system_log_views import SystemLogsView, SystemLogFilesView
from .blacklist_views import GlobalBlacklistView
__all__ = [
'HealthCheckView',
'LoginView', 'LogoutView', 'MeView', 'ChangePasswordView',
'SystemLogsView', 'SystemLogFilesView',
'GlobalBlacklistView',
]

View File

@@ -0,0 +1,80 @@
"""全局黑名单 API 视图"""
import logging
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.common.response_helpers import success_response, error_response
from apps.common.services import BlacklistService
logger = logging.getLogger(__name__)
class GlobalBlacklistView(APIView):
"""
全局黑名单规则 API
Endpoints:
- GET /api/blacklist/rules/ - 获取全局黑名单列表
- PUT /api/blacklist/rules/ - 全量替换规则(文本框保存场景)
设计说明:
- 使用 PUT 全量替换模式,适合"文本框每行一个规则"的前端场景
- 用户编辑文本框 -> 点击保存 -> 后端全量替换
架构MVS 模式
- View: 参数验证、响应格式化
- Service: 业务逻辑BlacklistService
- Model: 数据持久化BlacklistRule
"""
permission_classes = [IsAuthenticated]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.blacklist_service = BlacklistService()
def get(self, request):
"""
获取全局黑名单规则列表
返回格式:
{
"patterns": ["*.gov", "*.edu", "10.0.0.0/8"]
}
"""
rules = self.blacklist_service.get_global_rules()
patterns = list(rules.values_list('pattern', flat=True))
return success_response(data={'patterns': patterns})
def put(self, request):
"""
全量替换全局黑名单规则
请求格式:
{
"patterns": ["*.gov", "*.edu", "10.0.0.0/8"]
}
或者空数组清空所有规则:
{
"patterns": []
}
"""
patterns = request.data.get('patterns', [])
# 兼容字符串输入(换行分隔)
if isinstance(patterns, str):
patterns = [p for p in patterns.split('\n') if p.strip()]
if not isinstance(patterns, list):
return error_response(
code='VALIDATION_ERROR',
message='patterns 必须是数组'
)
# 调用 Service 层全量替换
result = self.blacklist_service.replace_global_rules(patterns)
return success_response(data=result)

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2026-01-02 04:45
# Generated by Django 5.2.7 on 2026-01-06 00:55
from django.db import migrations, models

View File

@@ -20,7 +20,7 @@ from pathlib import Path
from typing import Callable
from prefect import flow
from apps.scan.tasks.port_scan import (
export_scan_targets_task,
export_hosts_task,
run_and_stream_save_ports_task
)
from apps.scan.handlers.scan_flow_handlers import (
@@ -157,9 +157,9 @@ def _parse_port_count(tool_config: dict) -> int:
def _export_scan_targets(target_id: int, port_scan_dir: Path) -> tuple[str, int, str]:
def _export_hosts(target_id: int, port_scan_dir: Path) -> tuple[str, int, str]:
"""
导出扫描目标到文件
导出主机列表到文件
根据 Target 类型自动决定导出内容:
- DOMAIN: 从 Subdomain 表导出子域名
@@ -171,31 +171,31 @@ def _export_scan_targets(target_id: int, port_scan_dir: Path) -> tuple[str, int,
port_scan_dir: 端口扫描目录
Returns:
tuple: (targets_file, target_count, target_type)
tuple: (hosts_file, host_count, target_type)
"""
logger.info("Step 1: 导出扫描目标列表")
logger.info("Step 1: 导出主机列表")
targets_file = str(port_scan_dir / 'targets.txt')
export_result = export_scan_targets_task(
hosts_file = str(port_scan_dir / 'hosts.txt')
export_result = export_hosts_task(
target_id=target_id,
output_file=targets_file,
output_file=hosts_file,
batch_size=1000 # 每次读取 1000 条,优化内存占用
)
target_count = export_result['total_count']
host_count = export_result['total_count']
target_type = export_result.get('target_type', 'unknown')
logger.info(
"扫描目标导出完成 - 类型: %s, 文件: %s, 数量: %d",
"主机列表导出完成 - 类型: %s, 文件: %s, 数量: %d",
target_type,
export_result['output_file'],
target_count
host_count
)
if target_count == 0:
logger.warning("目标下没有可扫描的地址,无法执行端口扫描")
if host_count == 0:
logger.warning("目标下没有可扫描的主机,无法执行端口扫描")
return export_result['output_file'], target_count, target_type
return export_result['output_file'], host_count, target_type
def _run_scans_sequentially(
@@ -382,8 +382,8 @@ def port_scan_flow(
'scan_id': int,
'target': str,
'scan_workspace_dir': str,
'domains_file': str,
'domain_count': int,
'hosts_file': str,
'host_count': int,
'processed_records': int,
'executed_tasks': list,
'tool_stats': {
@@ -432,22 +432,22 @@ def port_scan_flow(
from apps.scan.utils import setup_scan_directory
port_scan_dir = setup_scan_directory(scan_workspace_dir, 'port_scan')
# Step 1: 导出扫描目标列表到文件(根据 Target 类型自动决定内容)
targets_file, target_count, target_type = _export_scan_targets(target_id, port_scan_dir)
# Step 1: 导出主机列表到文件(根据 Target 类型自动决定内容)
hosts_file, host_count, target_type = _export_hosts(target_id, port_scan_dir)
if target_count == 0:
logger.warning("跳过端口扫描:没有目标可扫描 - Scan ID: %s", scan_id)
user_log(scan_id, "port_scan", "Skipped: no targets to scan", "warning")
if host_count == 0:
logger.warning("跳过端口扫描:没有主机可扫描 - Scan ID: %s", scan_id)
user_log(scan_id, "port_scan", "Skipped: no hosts to scan", "warning")
return {
'success': True,
'scan_id': scan_id,
'target': target_name,
'scan_workspace_dir': scan_workspace_dir,
'targets_file': targets_file,
'target_count': 0,
'hosts_file': hosts_file,
'host_count': 0,
'target_type': target_type,
'processed_records': 0,
'executed_tasks': ['export_scan_targets'],
'executed_tasks': ['export_hosts'],
'tool_stats': {
'total': 0,
'successful': 0,
@@ -469,7 +469,7 @@ def port_scan_flow(
logger.info("Step 3: 串行执行扫描工具")
tool_stats, processed_records, successful_tool_names, failed_tools = _run_scans_sequentially(
enabled_tools=enabled_tools,
domains_file=targets_file, # 现在是 targets_file兼容原参数名
domains_file=hosts_file,
port_scan_dir=port_scan_dir,
scan_id=scan_id,
target_id=target_id,
@@ -481,7 +481,7 @@ def port_scan_flow(
user_log(scan_id, "port_scan", f"port_scan completed: found {processed_records} ports")
# 动态生成已执行的任务列表
executed_tasks = ['export_scan_targets', 'parse_config']
executed_tasks = ['export_hosts', 'parse_config']
executed_tasks.extend([f'run_and_stream_save_ports ({tool})' for tool in tool_stats.keys()])
return {
@@ -489,8 +489,8 @@ def port_scan_flow(
'scan_id': scan_id,
'target': target_name,
'scan_workspace_dir': scan_workspace_dir,
'targets_file': targets_file,
'target_count': target_count,
'hosts_file': hosts_file,
'host_count': host_count,
'target_type': target_type,
'processed_records': processed_records,
'executed_tasks': executed_tasks,
@@ -499,8 +499,8 @@ def port_scan_flow(
'successful': len(successful_tool_names),
'failed': len(failed_tools),
'successful_tools': successful_tool_names,
'failed_tools': failed_tools, # [{'tool': 'naabu_active', 'reason': '超时'}]
'details': tool_stats # 详细结果(保留向后兼容)
'failed_tools': failed_tools,
'details': tool_stats
}
}

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2026-01-02 04:45
# Generated by Django 5.2.7 on 2026-01-06 00:55
import django.contrib.postgres.fields
import django.db.models.deletion
@@ -31,6 +31,20 @@ class Migration(migrations.Migration):
'db_table': 'notification_settings',
},
),
migrations.CreateModel(
name='SubfinderProviderSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('providers', models.JSONField(default=dict, help_text='各 Provider 的 API Key 配置')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Subfinder Provider 配置',
'verbose_name_plural': 'Subfinder Provider 配置',
'db_table': 'subfinder_provider_settings',
},
),
migrations.CreateModel(
name='Notification',
fields=[
@@ -87,7 +101,22 @@ class Migration(migrations.Migration):
'verbose_name_plural': '扫描任务',
'db_table': 'scan',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='scan_created_0bb6c7_idx'), models.Index(fields=['target'], name='scan_target__718b9d_idx'), models.Index(fields=['deleted_at', '-created_at'], name='scan_deleted_eb17e8_idx')],
},
),
migrations.CreateModel(
name='ScanLog',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')], default='info', help_text='日志级别', max_length=10)),
('content', models.TextField(help_text='日志内容')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')),
('scan', models.ForeignKey(help_text='关联的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='scan.scan')),
],
options={
'verbose_name': '扫描日志',
'verbose_name_plural': '扫描日志',
'db_table': 'scan_log',
'ordering': ['created_at'],
},
),
migrations.CreateModel(
@@ -113,38 +142,34 @@ class Migration(migrations.Migration):
'verbose_name_plural': '定时扫描任务',
'db_table': 'scheduled_scan',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='scheduled_s_created_9b9c2e_idx'), models.Index(fields=['is_enabled', '-created_at'], name='scheduled_s_is_enab_23d660_idx'), models.Index(fields=['name'], name='scheduled_s_name_bf332d_idx')],
},
),
migrations.CreateModel(
name='ScanLog',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')], default='info', help_text='日志级别', max_length=10)),
('content', models.TextField(help_text='日志内容')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')),
('scan', models.ForeignKey(db_index=True, help_text='关联的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='scan.scan')),
],
options={
'verbose_name': '扫描日志',
'verbose_name_plural': '扫描日志',
'db_table': 'scan_log',
'ordering': ['created_at'],
'indexes': [models.Index(fields=['scan', 'created_at'], name='scan_log_scan_id_e8c8f5_idx')],
},
migrations.AddIndex(
model_name='scan',
index=models.Index(fields=['-created_at'], name='scan_created_0bb6c7_idx'),
),
migrations.CreateModel(
name='SubfinderProviderSettings',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('providers', models.JSONField(default=dict, help_text='各 Provider 的 API Key 配置')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Subfinder Provider 配置',
'verbose_name_plural': 'Subfinder Provider 配置',
'db_table': 'subfinder_provider_settings',
},
migrations.AddIndex(
model_name='scan',
index=models.Index(fields=['target'], name='scan_target__718b9d_idx'),
),
migrations.AddIndex(
model_name='scan',
index=models.Index(fields=['deleted_at', '-created_at'], name='scan_deleted_eb17e8_idx'),
),
migrations.AddIndex(
model_name='scanlog',
index=models.Index(fields=['scan', 'created_at'], name='scan_log_scan_id_c4814a_idx'),
),
migrations.AddIndex(
model_name='scheduledscan',
index=models.Index(fields=['-created_at'], name='scheduled_s_created_9b9c2e_idx'),
),
migrations.AddIndex(
model_name='scheduledscan',
index=models.Index(fields=['is_enabled', '-created_at'], name='scheduled_s_is_enab_23d660_idx'),
),
migrations.AddIndex(
model_name='scheduledscan',
index=models.Index(fields=['name'], name='scheduled_s_name_bf332d_idx'),
),
]

View File

@@ -17,7 +17,6 @@ from .scan_state_service import ScanStateService
from .scan_control_service import ScanControlService
from .scan_stats_service import ScanStatsService
from .scheduled_scan_service import ScheduledScanService
from .blacklist_service import BlacklistService
from .target_export_service import TargetExportService
__all__ = [
@@ -27,7 +26,6 @@ __all__ = [
'ScanControlService',
'ScanStatsService',
'ScheduledScanService',
'BlacklistService', # 黑名单过滤服务
'TargetExportService', # 目标导出服务
]

View File

@@ -1,82 +0,0 @@
"""
黑名单过滤服务
过滤敏感域名(如 .gov、.edu、.mil 等)
当前版本使用默认规则,后续将支持从前端配置加载。
"""
from typing import List, Optional
from django.db.models import QuerySet
import re
import logging
logger = logging.getLogger(__name__)
class BlacklistService:
"""
黑名单过滤服务 - 过滤敏感域名
TODO: 后续版本支持从前端配置加载黑名单规则
- 用户在开始扫描时配置黑名单 URL、域名、IP
- 黑名单规则存储在数据库中,与 Scan 或 Engine 关联
"""
# 默认黑名单正则规则
DEFAULT_PATTERNS = [
r'\.gov$', # .gov 结尾
r'\.gov\.[a-z]{2}$', # .gov.cn, .gov.uk 等
]
def __init__(self, patterns: Optional[List[str]] = None):
"""
初始化黑名单服务
Args:
patterns: 正则表达式列表None 使用默认规则
"""
self.patterns = patterns or self.DEFAULT_PATTERNS
self._compiled_patterns = [re.compile(p) for p in self.patterns]
def filter_queryset(
self,
queryset: QuerySet,
url_field: str = 'url'
) -> QuerySet:
"""
数据库层面过滤 queryset
使用 PostgreSQL 正则表达式排除黑名单 URL
Args:
queryset: 原始 queryset
url_field: URL 字段名
Returns:
QuerySet: 过滤后的 queryset
"""
for pattern in self.patterns:
queryset = queryset.exclude(**{f'{url_field}__regex': pattern})
return queryset
def filter_url(self, url: str) -> bool:
"""
检查单个 URL 是否通过黑名单过滤
Args:
url: 要检查的 URL
Returns:
bool: True 表示通过不在黑名单False 表示被过滤
"""
for pattern in self._compiled_patterns:
if pattern.search(url):
return False
return True
# TODO: 后续版本实现
# @classmethod
# def from_scan(cls, scan_id: int) -> 'BlacklistService':
# """从数据库加载扫描配置的黑名单规则"""
# pass

View File

@@ -10,37 +10,58 @@
import ipaddress
import logging
from pathlib import Path
from typing import Dict, Any, Optional, Iterator
from typing import Dict, Any, Optional, List
from django.db.models import QuerySet
from .blacklist_service import BlacklistService
from apps.common.utils import BlacklistFilter
logger = logging.getLogger(__name__)
def create_export_service(target_id: int) -> 'TargetExportService':
"""
工厂函数:创建带黑名单过滤的导出服务
Args:
target_id: 目标 ID用于加载黑名单规则
Returns:
TargetExportService: 配置好黑名单过滤器的导出服务实例
"""
from apps.common.services import BlacklistService
rules = BlacklistService().get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
return TargetExportService(blacklist_filter=blacklist_filter)
class TargetExportService:
"""
目标导出服务 - 提供统一的目标提取和文件导出功能
使用方式:
# Task 层决定数据源
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
from apps.common.services import BlacklistService
from apps.common.utils import BlacklistFilter
# 获取规则并创建过滤器
blacklist_service = BlacklistService()
rules = blacklist_service.get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
# 使用导出服务
blacklist_service = BlacklistService()
export_service = TargetExportService(blacklist_service=blacklist_service)
export_service = TargetExportService(blacklist_filter=blacklist_filter)
result = export_service.export_urls(target_id, output_path, queryset)
"""
def __init__(self, blacklist_service: Optional[BlacklistService] = None):
def __init__(self, blacklist_filter: Optional[BlacklistFilter] = None):
"""
初始化导出服务
Args:
blacklist_service: 黑名单过滤服务None 表示禁用过滤
blacklist_filter: 黑名单过滤None 表示禁用过滤
"""
self.blacklist_service = blacklist_service
self.blacklist_filter = blacklist_filter
def export_urls(
self,
@@ -79,19 +100,15 @@ class TargetExportService:
logger.info("开始导出 URL - target_id=%s, output=%s", target_id, output_path)
# 应用黑名单过滤(数据库层面)
if self.blacklist_service:
# 注意queryset 应该是原始 queryset不是 values_list
# 这里假设 Task 层传入的是 values_list需要在 Task 层处理过滤
pass
total_count = 0
filtered_count = 0
try:
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
for url in queryset.iterator(chunk_size=batch_size):
if url:
# Python 层面黑名单过滤
if self.blacklist_service and not self.blacklist_service.filter_url(url):
# 黑名单过滤
if self.blacklist_filter and not self.blacklist_filter.is_allowed(url):
filtered_count += 1
continue
f.write(f"{url}\n")
total_count += 1
@@ -102,6 +119,9 @@ class TargetExportService:
logger.error("文件写入失败: %s - %s", output_path, e)
raise
if filtered_count > 0:
logger.info("黑名单过滤: 过滤 %d 个 URL", filtered_count)
# 默认值回退模式
if total_count == 0:
total_count = self._generate_default_urls(target_id, output_file)
@@ -206,18 +226,18 @@ class TargetExportService:
def _should_write_url(self, url: str) -> bool:
"""检查 URL 是否应该写入(通过黑名单过滤)"""
if self.blacklist_service:
return self.blacklist_service.filter_url(url)
if self.blacklist_filter:
return self.blacklist_filter.is_allowed(url)
return True
def export_targets(
def export_hosts(
self,
target_id: int,
output_path: str,
batch_size: int = 1000
) -> Dict[str, Any]:
"""
域名/IP 导出函数(用于端口扫描)
主机列表导出函数(用于端口扫描)
根据 Target 类型选择导出逻辑:
- DOMAIN: 从 Subdomain 表流式导出子域名
@@ -255,7 +275,7 @@ class TargetExportService:
target_name = target.name
logger.info(
"开始导出扫描目标 - Target ID: %d, Name: %s, Type: %s, 输出文件: %s",
"开始导出主机列表 - Target ID: %d, Name: %s, Type: %s, 输出文件: %s",
target_id, target_name, target_type, output_path
)
@@ -277,7 +297,7 @@ class TargetExportService:
raise ValueError(f"不支持的目标类型: {target_type}")
logger.info(
"扫描目标导出完成 - 类型: %s, 总数: %d, 文件: %s",
"主机列表导出完成 - 类型: %s, 总数: %d, 文件: %s",
type_desc, total_count, output_path
)
@@ -295,7 +315,7 @@ class TargetExportService:
output_path: Path,
batch_size: int
) -> int:
"""导出域名类型目标的子域名"""
"""导出域名类型目标的根域名 + 子域名"""
from apps.asset.services.asset.subdomain_service import SubdomainService
subdomain_service = SubdomainService()
@@ -305,23 +325,27 @@ class TargetExportService:
)
total_count = 0
written_domains = set() # 去重(子域名表可能已包含根域名)
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
# 1. 先写入根域名
if self._should_write_target(target_name):
f.write(f"{target_name}\n")
written_domains.add(target_name)
total_count += 1
# 2. 再写入子域名(跳过已写入的根域名)
for domain_name in domain_iterator:
if domain_name in written_domains:
continue
if self._should_write_target(domain_name):
f.write(f"{domain_name}\n")
written_domains.add(domain_name)
total_count += 1
if total_count % 10000 == 0:
logger.info("已导出 %d 个域名...", total_count)
# 默认值模式:如果没有子域名,使用根域名
if total_count == 0:
logger.info("采用默认域名:%s (target_id=%d)", target_name, target_id)
if self._should_write_target(target_name):
with open(output_path, 'w', encoding='utf-8') as f:
f.write(f"{target_name}\n")
total_count = 1
return total_count
def _export_ip(self, target_name: str, output_path: Path) -> int:
@@ -359,6 +383,6 @@ class TargetExportService:
def _should_write_target(self, target: str) -> bool:
"""检查目标是否应该写入(通过黑名单过滤)"""
if self.blacklist_service:
return self.blacklist_service.filter_url(target)
if self.blacklist_filter:
return self.blacklist_filter.is_allowed(target)
return True

View File

@@ -8,7 +8,8 @@ import logging
from prefect import task
from apps.asset.models import WebSite
from apps.scan.services import TargetExportService, BlacklistService
from apps.scan.services import TargetExportService
from apps.scan.services.target_export_service import create_export_service
logger = logging.getLogger(__name__)
@@ -49,9 +50,8 @@ def export_sites_task(
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用 TargetExportService 处理导出
blacklist_service = BlacklistService()
export_service = TargetExportService(blacklist_service=blacklist_service)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
target_id=target_id,

View File

@@ -10,7 +10,7 @@ import logging
from prefect import task
from apps.asset.models import WebSite
from apps.scan.services import TargetExportService, BlacklistService
from apps.scan.services.target_export_service import create_export_service
logger = logging.getLogger(__name__)
@@ -46,9 +46,8 @@ def export_urls_for_fingerprint_task(
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用 TargetExportService 处理导出
blacklist_service = BlacklistService()
export_service = TargetExportService(blacklist_service=blacklist_service)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
target_id=target_id,

View File

@@ -4,12 +4,12 @@
提供端口扫描流程所需的原子化任务
"""
from .export_scan_targets_task import export_scan_targets_task
from .export_hosts_task import export_hosts_task
from .run_and_stream_save_ports_task import run_and_stream_save_ports_task
from .types import PortScanRecord
__all__ = [
'export_scan_targets_task',
'export_hosts_task',
'run_and_stream_save_ports_task',
'PortScanRecord',
]

View File

@@ -1,7 +1,7 @@
"""
导出扫描目标 TXT 文件的 Task
导出主机列表 TXT 文件的 Task
使用 TargetExportService.export_targets() 统一处理导出逻辑
使用 TargetExportService.export_hosts() 统一处理导出逻辑
根据 Target 类型决定导出内容
- DOMAIN: Subdomain 表导出子域名
@@ -11,19 +11,19 @@
import logging
from prefect import task
from apps.scan.services import TargetExportService, BlacklistService
from apps.scan.services.target_export_service import create_export_service
logger = logging.getLogger(__name__)
@task(name="export_scan_targets")
def export_scan_targets_task(
@task(name="export_hosts")
def export_hosts_task(
target_id: int,
output_file: str,
batch_size: int = 1000
) -> dict:
"""
导出扫描目标 TXT 文件
导出主机列表 TXT 文件
根据 Target 类型自动决定导出内容
- DOMAIN: Subdomain 表导出子域名流式处理支持 10+ 域名
@@ -47,11 +47,10 @@ def export_scan_targets_task(
ValueError: Target 不存在
IOError: 文件写入失败
"""
# 使用 TargetExportService 处理导出
blacklist_service = BlacklistService()
export_service = TargetExportService(blacklist_service=blacklist_service)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_targets(
result = export_service.export_hosts(
target_id=target_id,
output_path=output_file,
batch_size=batch_size

View File

@@ -14,7 +14,9 @@ from pathlib import Path
from prefect import task
from apps.asset.services import HostPortMappingService
from apps.scan.services import TargetExportService, BlacklistService
from apps.scan.services.target_export_service import create_export_service
from apps.common.services import BlacklistService
from apps.common.utils import BlacklistFilter
logger = logging.getLogger(__name__)
@@ -80,8 +82,8 @@ def export_site_urls_task(
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
# 初始化黑名单服务
blacklist_service = BlacklistService()
# 获取规则并创建过滤器
blacklist_filter = BlacklistFilter(BlacklistService().get_rules(target_id))
# 直接查询 HostPortMapping 表,按 host 排序
service = HostPortMappingService()
@@ -100,11 +102,14 @@ def export_site_urls_task(
host = assoc['host']
port = assoc['port']
# 先校验 host通过了再生成 URL
if not blacklist_filter.is_allowed(host):
continue
# 根据端口号生成URL
for url in _generate_urls_from_port(host, port):
if blacklist_service.filter_url(url):
f.write(f"{url}\n")
total_urls += 1
f.write(f"{url}\n")
total_urls += 1
if association_count % 1000 == 0:
logger.info("已处理 %d 条关联,生成 %d 个URL...", association_count, total_urls)
@@ -114,9 +119,9 @@ def export_site_urls_task(
association_count, total_urls, str(output_path)
)
# 默认值回退模式:使用 TargetExportService
# 默认值回退模式:使用工厂函数创建导出服务
if total_urls == 0:
export_service = TargetExportService(blacklist_service=blacklist_service)
export_service = create_export_service(target_id)
total_urls = export_service._generate_default_urls(target_id, output_path)
return {

View File

@@ -111,6 +111,7 @@ def save_domains_task(
continue
# 只有通过验证的域名才添加到批次和计数
# 注意:不在此处过滤黑名单,最大化资产发现
batch.append(domain)
total_domains += 1

View File

@@ -10,7 +10,7 @@ from prefect import task
from typing import Optional
from apps.asset.models import WebSite
from apps.scan.services import TargetExportService, BlacklistService
from apps.scan.services.target_export_service import create_export_service
logger = logging.getLogger(__name__)
@@ -56,9 +56,8 @@ def export_sites_task(
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用 TargetExportService 处理导出
blacklist_service = BlacklistService()
export_service = TargetExportService(blacklist_service=blacklist_service)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
target_id=target_id,

View File

@@ -10,7 +10,7 @@ from typing import Dict, Optional
from prefect import task
from apps.asset.models import Endpoint
from apps.scan.services import TargetExportService, BlacklistService
from apps.scan.services.target_export_service import create_export_service
logger = logging.getLogger(__name__)
@@ -46,9 +46,8 @@ def export_endpoints_task(
# 构建数据源 querysetTask 层决定数据源)
queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用 TargetExportService 处理导出
blacklist_service = BlacklistService()
export_service = TargetExportService(blacklist_service=blacklist_service)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
target_id=target_id,

View File

@@ -3,6 +3,7 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.exceptions import NotFound, APIException
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.utils import DatabaseError, IntegrityError, OperationalError
import logging
@@ -33,7 +34,8 @@ class ScanViewSet(viewsets.ModelViewSet):
"""扫描任务视图集"""
serializer_class = ScanSerializer
pagination_class = BasePagination
filter_backends = [SearchFilter]
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['target'] # 支持 ?target=123 过滤
search_fields = ['target__name'] # 按目标名称搜索
def get_queryset(self):

View File

@@ -37,6 +37,11 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
- PUT /scheduled-scans/{id}/ 更新定时扫描
- DELETE /scheduled-scans/{id}/ 删除定时扫描
- POST /scheduled-scans/{id}/toggle/ 切换启用状态
查询参数:
- target_id: 按目标 ID 过滤
- organization_id: 按组织 ID 过滤
- search: 按名称搜索
"""
queryset = ScheduledScan.objects.all().order_by('-created_at')
@@ -49,6 +54,19 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
super().__init__(*args, **kwargs)
self.service = ScheduledScanService()
def get_queryset(self):
"""支持按 target_id 和 organization_id 过滤"""
queryset = super().get_queryset()
target_id = self.request.query_params.get('target_id')
organization_id = self.request.query_params.get('organization_id')
if target_id:
queryset = queryset.filter(target_id=target_id)
if organization_id:
queryset = queryset.filter(organization_id=organization_id)
return queryset
def get_serializer_class(self):
"""根据 action 返回不同的序列化器"""
if self.action == 'create':

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2026-01-02 04:45
# Generated by Django 5.2.7 on 2026-01-06 00:55
from django.db import migrations, models

View File

@@ -99,31 +99,6 @@ class TargetService:
# ==================== 创建操作 ====================
def create_or_get_target(
self,
name: str,
target_type: str
) -> Tuple[Target, bool]:
"""
创建或获取目标
Args:
name: 目标名称
target_type: 目标类型
Returns:
(Target对象, 是否新创建)
"""
logger.debug("创建或获取目标 - Name: %s, Type: %s", name, target_type)
target, created = self.repo.get_or_create(name, target_type)
if created:
logger.info("创建新目标 - ID: %s, Name: %s", target.id, name)
else:
logger.debug("目标已存在 - ID: %s, Name: %s", target.id, name)
return target, created
def batch_create_targets(
self,
targets_data: List[Dict[str, Any]],

View File

@@ -11,6 +11,8 @@ from .services.target_service import TargetService
from .services.organization_service import OrganizationService
from apps.common.pagination import BasePagination
from apps.common.response_helpers import success_response
from apps.common.models import BlacklistRule
from apps.common.serializers import TargetBlacklistRuleSerializer
logger = logging.getLogger(__name__)
@@ -405,3 +407,48 @@ class TargetViewSet(viewsets.ModelViewSet):
# GET /api/targets/{id}/ip-addresses/ -> HostPortMappingViewSet
# GET /api/targets/{id}/ip-addresses/export/ -> HostPortMappingViewSet.export
# GET /api/targets/{id}/vulnerabilities/ -> VulnerabilityViewSet
# ==================== 黑名单管理 ====================
@action(detail=True, methods=['get', 'put'], url_path='blacklist')
def blacklist(self, request, pk=None):
"""
Target 黑名单规则管理
GET /api/targets/{id}/blacklist/ - 获取 Target 黑名单列表
PUT /api/targets/{id}/blacklist/ - 全量替换规则(文本框保存场景)
设计说明:
- 使用 PUT 全量替换模式,适合"文本框每行一个规则"的前端场景
- 用户编辑文本框 -> 点击保存 -> 后端全量替换
架构MVS 模式
- View: 参数验证、响应格式化
- Service: 业务逻辑BlacklistService
- Model: 数据持久化BlacklistRule
"""
from apps.common.services import BlacklistService
target = self.get_object()
blacklist_service = BlacklistService()
if request.method == 'GET':
# 获取 Target 的黑名单规则
rules = blacklist_service.get_target_rules(target.id)
patterns = list(rules.values_list('pattern', flat=True))
return success_response(data={'patterns': patterns})
elif request.method == 'PUT':
# 全量替换
patterns = request.data.get('patterns', [])
if not isinstance(patterns, list):
return Response(
{'error': {'code': 'VALIDATION_ERROR', 'message': 'patterns 必须是数组'}},
status=status.HTTP_400_BAD_REQUEST
)
# 调用 Service 层全量替换
result = blacklist_service.replace_target_rules(target, patterns)
return success_response(data=result)

View File

@@ -51,6 +51,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
# 第三方应用
'rest_framework',
'django_filters', # DRF 过滤器支持
'drf_yasg',
'corsheaders',
'channels', # WebSocket 支持

View File

@@ -11,6 +11,9 @@ setuptools==75.6.0
# CORS 支持
django-cors-headers==4.3.1
# 过滤器支持
django-filter==24.3
# 环境变量管理
python-dotenv==1.0.1

View File

@@ -63,11 +63,7 @@ wait_for_server() {
run_migrations() {
log_step "执行数据库迁移..."
# 开发环境:先 makemigrations
if [ "$DEV_MODE" = "true" ]; then
docker compose exec -T server python backend/manage.py makemigrations --noinput 2>/dev/null || true
fi
# 迁移文件应手动生成并提交到仓库,这里只执行 migrate
docker compose exec -T server python backend/manage.py migrate --noinput
log_info "数据库迁移完成"
}

View File

@@ -3,26 +3,21 @@ set -e
echo "[START] 启动 XingRin Server..."
# 1. 生成和迁移数据库
echo " [1/3] 生成数据库迁移文件..."
# 1. 执行数据库迁移(迁移文件应提交到仓库,这里只执行 migrate
echo " [1/3] 执行数据库迁移..."
cd /app/backend
python manage.py makemigrations
echo " ✓ 迁移文件生成完成"
echo " [1.1/3] 执行数据库迁移..."
python manage.py migrate --noinput
echo " ✓ 数据库迁移完成"
echo " [1.2/3] 初始化默认扫描引擎..."
echo " [1.1/3] 初始化默认扫描引擎..."
python manage.py init_default_engine
echo " ✓ 默认扫描引擎已就绪"
echo " [1.3/3] 初始化默认目录字典..."
echo " [1.2/3] 初始化默认目录字典..."
python manage.py init_wordlists
echo " ✓ 默认目录字典已就绪"
echo " [1.4/3] 初始化默认指纹库..."
echo " [1.3/3] 初始化默认指纹库..."
python manage.py init_fingerprints
echo " ✓ 默认指纹库已就绪"

View File

@@ -155,7 +155,11 @@ fi
echo -e "${GREEN}[OK]${NC} 服务已启动"
# 数据初始化
./scripts/init-data.sh
if [ "$DEV_MODE" = true ]; then
./scripts/init-data.sh --dev
else
./scripts/init-data.sh
fi
# 静默模式下不显示结果(由调用方显示)
if [ "$QUIET_MODE" = true ]; then

View File

@@ -0,0 +1,132 @@
"use client"
import React, { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { AlertTriangle, Loader2, Ban } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useGlobalBlacklist, useUpdateGlobalBlacklist } from "@/hooks/use-global-blacklist"
/**
* Global blacklist settings page
*/
export default function GlobalBlacklistPage() {
const t = useTranslations("pages.settings.blacklist")
const [blacklistText, setBlacklistText] = useState("")
const [hasChanges, setHasChanges] = useState(false)
const { data, isLoading, error } = useGlobalBlacklist()
const updateBlacklist = useUpdateGlobalBlacklist()
// Initialize text when data loads
useEffect(() => {
if (data?.patterns) {
setBlacklistText(data.patterns.join("\n"))
setHasChanges(false)
}
}, [data])
// Handle text change
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBlacklistText(e.target.value)
setHasChanges(true)
}
// Handle save
const handleSave = () => {
const patterns = blacklistText
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
updateBlacklist.mutate(
{ patterns },
{
onSuccess: () => {
setHasChanges(false)
},
}
)
}
if (isLoading) {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-[400px] w-full" />
</div>
)
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center py-12">
<AlertTriangle className="h-10 w-10 text-destructive mb-4" />
<p className="text-muted-foreground">{t("loadError")}</p>
</div>
)
}
return (
<div className="flex flex-1 flex-col gap-4 p-4">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
{/* Blacklist card */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Ban className="h-5 w-5 text-muted-foreground" />
<CardTitle>{t("card.title")}</CardTitle>
</div>
<CardDescription>{t("card.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Rules hint */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{t("rules.title")}:</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">*.gov</code> {t("rules.domain")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">*cdn*</code> {t("rules.keyword")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">192.168.1.1</code> {t("rules.ip")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">10.0.0.0/8</code> {t("rules.cidr")}</span>
</div>
{/* Scope hint */}
<div className="rounded-lg border bg-muted/50 p-3 text-sm">
<p className="text-muted-foreground">{t("scopeHint")}</p>
</div>
{/* Input */}
<Textarea
value={blacklistText}
onChange={handleTextChange}
placeholder={t("placeholder")}
className="min-h-[320px] font-mono text-sm"
/>
{/* Save button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!hasChanges || updateBlacklist.isPending}
>
{updateBlacklist.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("save")}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -5,15 +5,15 @@ import { useEffect } from "react"
/**
* Target detail page (compatible with old routes)
* Automatically redirects to websites page
* Automatically redirects to overview page
*/
export default function TargetDetailsPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
useEffect(() => {
// Redirect to websites page
router.replace(`/target/${id}/websites/`)
// Redirect to overview page
router.replace(`/target/${id}/overview/`)
}, [id, router])
return null

View File

@@ -12,7 +12,8 @@ import { useTranslations } from "next-intl"
/**
* Target detail layout
* Provides shared target information and navigation for all sub-pages
* Two-level navigation: Overview / Assets / Vulnerabilities
* Assets has secondary navigation for different asset types
*/
export default function TargetLayout({
children,
@@ -30,26 +31,52 @@ export default function TargetLayout({
error
} = useTarget(Number(id))
// Get currently active tab
const getActiveTab = () => {
if (pathname.includes("/subdomain")) return "subdomain"
if (pathname.includes("/endpoints")) return "endpoints"
if (pathname.includes("/websites")) return "websites"
if (pathname.includes("/directories")) return "directories"
// Get primary navigation active tab
const getPrimaryTab = () => {
if (pathname.includes("/overview")) return "overview"
if (pathname.includes("/vulnerabilities")) return "vulnerabilities"
if (pathname.includes("/ip-addresses")) return "ip-addresses"
return ""
if (pathname.includes("/settings")) return "settings"
// All asset pages fall under "assets"
if (
pathname.includes("/websites") ||
pathname.includes("/subdomain") ||
pathname.includes("/ip-addresses") ||
pathname.includes("/endpoints") ||
pathname.includes("/directories")
) {
return "assets"
}
return "overview"
}
// Get secondary navigation active tab (for assets)
const getSecondaryTab = () => {
if (pathname.includes("/websites")) return "websites"
if (pathname.includes("/subdomain")) return "subdomain"
if (pathname.includes("/ip-addresses")) return "ip-addresses"
if (pathname.includes("/endpoints")) return "endpoints"
if (pathname.includes("/directories")) return "directories"
return "websites"
}
// Check if we should show secondary navigation
const showSecondaryNav = getPrimaryTab() === "assets"
// Tab path mapping
const basePath = `/target/${id}`
const tabPaths = {
subdomain: `${basePath}/subdomain/`,
endpoints: `${basePath}/endpoints/`,
websites: `${basePath}/websites/`,
directories: `${basePath}/directories/`,
const primaryPaths = {
overview: `${basePath}/overview/`,
assets: `${basePath}/websites/`, // Default to websites when clicking assets
vulnerabilities: `${basePath}/vulnerabilities/`,
settings: `${basePath}/settings/`,
}
const secondaryPaths = {
websites: `${basePath}/websites/`,
subdomain: `${basePath}/subdomain/`,
"ip-addresses": `${basePath}/ip-addresses/`,
endpoints: `${basePath}/endpoints/`,
directories: `${basePath}/directories/`,
}
// Get counts for each tab from target data
@@ -62,27 +89,24 @@ export default function TargetLayout({
"ip-addresses": (target as any)?.summary?.ips || 0,
}
// Calculate total assets count
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints + counts.directories
// Loading state
if (isLoading) {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* Page header skeleton */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div className="w-full max-w-xl space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-md" />
<Skeleton className="h-7 w-48" />
</div>
<Skeleton className="h-4 w-72" />
</div>
{/* Header skeleton */}
<div className="flex items-center gap-2 px-4 lg:px-6">
<Skeleton className="h-4 w-16" />
<span className="text-muted-foreground">/</span>
<Skeleton className="h-4 w-32" />
</div>
{/* Tabs navigation skeleton */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div className="flex gap-2">
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-24" />
</div>
{/* Tabs skeleton */}
<div className="flex gap-1 px-4 lg:px-6">
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-24" />
</div>
</div>
)
@@ -123,74 +147,38 @@ export default function TargetLayout({
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* Page header */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Target />
{target.name}
</h2>
<p className="text-muted-foreground">{target.description || t("noDescription")}</p>
</div>
{/* Header: Page label + Target name */}
<div className="flex items-center gap-2 text-sm px-4 lg:px-6">
<span className="text-muted-foreground">{t("breadcrumb.targetDetail")}</span>
<span className="text-muted-foreground">/</span>
<span className="font-medium flex items-center gap-1.5">
<Target className="h-4 w-4" />
{target.name}
</span>
</div>
{/* Tabs navigation - Use Link to ensure progress bar is triggered */}
<div className="flex items-center justify-between px-4 lg:px-6">
<Tabs value={getActiveTab()} className="w-full">
{/* Primary navigation */}
<div className="px-4 lg:px-6">
<Tabs value={getPrimaryTab()}>
<TabsList>
<TabsTrigger value="websites" asChild>
<Link href={tabPaths.websites} className="flex items-center gap-0.5">
Websites
{counts.websites > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.websites}
</Badge>
)}
<TabsTrigger value="overview" asChild>
<Link href={primaryPaths.overview} className="flex items-center gap-0.5">
{t("tabs.overview")}
</Link>
</TabsTrigger>
<TabsTrigger value="subdomain" asChild>
<Link href={tabPaths.subdomain} className="flex items-center gap-0.5">
Subdomains
{counts.subdomain > 0 && (
<TabsTrigger value="assets" asChild>
<Link href={primaryPaths.assets} className="flex items-center gap-0.5">
{t("tabs.assets")}
{totalAssets > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.subdomain}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="ip-addresses" asChild>
<Link href={tabPaths["ip-addresses"]} className="flex items-center gap-0.5">
IP Addresses
{counts["ip-addresses"] > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts["ip-addresses"]}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="endpoints" asChild>
<Link href={tabPaths.endpoints} className="flex items-center gap-0.5">
URLs
{counts.endpoints > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.endpoints}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="directories" asChild>
<Link href={tabPaths.directories} className="flex items-center gap-0.5">
Directories
{counts.directories > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.directories}
{totalAssets}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="vulnerabilities" asChild>
<Link href={tabPaths.vulnerabilities} className="flex items-center gap-0.5">
Vulnerabilities
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-0.5">
{t("tabs.vulnerabilities")}
{counts.vulnerabilities > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.vulnerabilities}
@@ -198,10 +186,75 @@ export default function TargetLayout({
)}
</Link>
</TabsTrigger>
<TabsTrigger value="settings" asChild>
<Link href={primaryPaths.settings} className="flex items-center gap-0.5">
{t("tabs.settings")}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Secondary navigation (only for assets) */}
{showSecondaryNav && (
<div className="flex items-center px-4 lg:px-6">
<Tabs value={getSecondaryTab()} className="w-full">
<TabsList variant="underline">
<TabsTrigger value="websites" variant="underline" asChild>
<Link href={secondaryPaths.websites} className="flex items-center gap-0.5">
Websites
{counts.websites > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.websites}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="subdomain" variant="underline" asChild>
<Link href={secondaryPaths.subdomain} className="flex items-center gap-0.5">
Subdomains
{counts.subdomain > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.subdomain}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="ip-addresses" variant="underline" asChild>
<Link href={secondaryPaths["ip-addresses"]} className="flex items-center gap-0.5">
IPs
{counts["ip-addresses"] > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts["ip-addresses"]}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="endpoints" variant="underline" asChild>
<Link href={secondaryPaths.endpoints} className="flex items-center gap-0.5">
URLs
{counts.endpoints > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.endpoints}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="directories" variant="underline" asChild>
<Link href={secondaryPaths.directories} className="flex items-center gap-0.5">
Directories
{counts.directories > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.directories}
</Badge>
)}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{/* Sub-page content */}
{children}
</div>

View File

@@ -0,0 +1,19 @@
"use client"
import { useParams } from "next/navigation"
import { TargetOverview } from "@/components/target/target-overview"
/**
* Target overview page
* Displays target statistics and summary information
*/
export default function TargetOverviewPage() {
const { id } = useParams<{ id: string }>()
const targetId = Number(id)
return (
<div className="px-4 lg:px-6">
<TargetOverview targetId={targetId} />
</div>
)
}

View File

@@ -5,15 +5,15 @@ import { useEffect } from "react"
/**
* Target detail default page
* Automatically redirects to websites page
* Automatically redirects to overview page
*/
export default function TargetDetailPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
useEffect(() => {
// Redirect to websites page
router.replace(`/target/${id}/websites/`)
// Redirect to overview page
router.replace(`/target/${id}/overview/`)
}, [id, router])
return null

View File

@@ -0,0 +1,19 @@
"use client"
import { useParams } from "next/navigation"
import { TargetSettings } from "@/components/target/target-settings"
/**
* Target settings page
* Contains blacklist configuration and other settings
*/
export default function TargetSettingsPage() {
const { id } = useParams<{ id: string }>()
const targetId = Number(id)
return (
<div className="px-4 lg:px-6">
<TargetSettings targetId={targetId} />
</div>
)
}

View File

@@ -18,6 +18,7 @@ import {
IconMessageReport, // Feedback icon
IconSearch, // Search icon
IconKey, // API Key icon
IconBan, // Blacklist icon
} from "@tabler/icons-react"
// Import internationalization hook
import { useTranslations } from 'next-intl'
@@ -174,6 +175,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: "/settings/api-keys/",
icon: IconKey,
},
{
name: t('globalBlacklist'),
url: "/settings/blacklist/",
icon: IconBan,
},
]
return (

View File

@@ -161,6 +161,7 @@ interface CreateColumnsProps {
handleStop: (scan: ScanRecord) => void
handleViewProgress?: (scan: ScanRecord) => void
t: ScanHistoryTranslations
hideTargetColumn?: boolean
}
/**
@@ -173,7 +174,9 @@ export const createScanHistoryColumns = ({
handleStop,
handleViewProgress,
t,
}: CreateColumnsProps): ColumnDef<ScanRecord>[] => [
hideTargetColumn = false,
}: CreateColumnsProps): ColumnDef<ScanRecord>[] => {
const columns: ColumnDef<ScanRecord>[] = [
{
id: "select",
size: 40,
@@ -574,3 +577,11 @@ export const createScanHistoryColumns = ({
enableHiding: false,
},
]
// Filter out targetName column if hideTargetColumn is true
if (hideTargetColumn) {
return columns.filter(col => (col as any).accessorKey !== 'targetName')
}
return columns
}

View File

@@ -27,6 +27,7 @@ interface ScanHistoryDataTableProps {
onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void
hideToolbar?: boolean
hidePagination?: boolean
pageSizeOptions?: number[]
}
/**
@@ -50,6 +51,7 @@ export function ScanHistoryDataTable({
onPaginationChange,
hideToolbar = false,
hidePagination = false,
pageSizeOptions,
}: ScanHistoryDataTableProps) {
const t = useTranslations("common.status")
const tScan = useTranslations("scan.history")
@@ -84,6 +86,7 @@ export function ScanHistoryDataTable({
paginationInfo={paginationInfo}
onPaginationChange={onPaginationChange}
hidePagination={hidePagination}
pageSizeOptions={pageSizeOptions}
// Selection
onSelectionChange={onSelectionChange}
// Bulk operations

View File

@@ -31,9 +31,14 @@ import { ScanProgressDialog, buildScanProgressData, type ScanProgressData } from
*/
interface ScanHistoryListProps {
hideToolbar?: boolean
targetId?: number // Filter by target ID
pageSize?: number // Custom page size
hideTargetColumn?: boolean // Hide target column (useful when showing scans for a specific target)
pageSizeOptions?: number[] // Custom page size options
hidePagination?: boolean // Hide pagination completely
}
export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: customPageSize, hideTargetColumn = false, pageSizeOptions, hidePagination = false }: ScanHistoryListProps) {
const queryClient = useQueryClient()
const [selectedScans, setSelectedScans] = useState<ScanRecord[]>([])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
@@ -97,7 +102,7 @@ export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
// Pagination state
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
pageSize: customPageSize || 10,
})
// Search state
@@ -115,6 +120,7 @@ export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
page: pagination.pageIndex + 1, // API page numbers start from 1
pageSize: pagination.pageSize,
search: searchQuery || undefined,
target: targetId,
})
// Reset search state when request completes
@@ -278,8 +284,9 @@ export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
handleStop: handleStopScan,
handleViewProgress,
t: translations,
hideTargetColumn,
}),
[navigate, translations]
[navigate, translations, hideTargetColumn]
)
// Error handling
@@ -330,6 +337,8 @@ export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
}}
onPaginationChange={handlePaginationChange}
hideToolbar={hideToolbar}
pageSizeOptions={pageSizeOptions}
hidePagination={hidePagination}
/>
{/* Delete confirmation dialog */}

View File

@@ -104,10 +104,12 @@ export function CreateScheduledScanDialog({
{ id: 5, title: t("steps.scheduleSettings"), icon: IconClock },
]
// Preset mode: skip target selection but keep basic info for name editing
const PRESET_STEPS = [
{ id: 1, title: t("steps.selectEngine"), icon: IconSettings },
{ id: 2, title: t("steps.editConfig"), icon: IconCode },
{ id: 3, title: t("steps.scheduleSettings"), icon: IconClock },
{ id: 1, title: t("steps.basicInfo"), icon: IconInfoCircle },
{ id: 2, title: t("steps.selectEngine"), icon: IconSettings },
{ id: 3, title: t("steps.editConfig"), icon: IconCode },
{ id: 4, title: t("steps.scheduleSettings"), icon: IconClock },
]
const [orgSearchInput, setOrgSearchInput] = React.useState("")
@@ -240,15 +242,18 @@ export function CreateScheduledScanDialog({
const validateCurrentStep = (): boolean => {
if (hasPreset) {
switch (currentStep) {
case 1: // Select engine
case 1: // Basic info (preset mode)
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
return true
case 2: // Select engine
if (!selectedPresetId) { toast.error(t("form.scanEngineRequired")); return false }
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
return true
case 2: // Edit config
case 3: // Edit config
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
return true
case 3: // Schedule
case 4: // Schedule
const parts = cronExpression.trim().split(/\s+/)
if (parts.length !== 5) { toast.error(t("form.cronRequired")); return false }
return true
@@ -352,7 +357,7 @@ export function CreateScheduledScanDialog({
</DialogHeader>
<div className="border-t h-[480px] overflow-hidden">
{/* Step 1: Basic Info + Scan Mode */}
{/* Step 1: Basic Info + Scan Mode (full mode only) */}
{currentStep === 1 && !hasPreset && (
<div className="p-6 space-y-6 overflow-y-auto h-full">
<div className="space-y-2">
@@ -394,6 +399,29 @@ export function CreateScheduledScanDialog({
</div>
)}
{/* Step 1: Basic Info (preset mode - name only, target is locked) */}
{currentStep === 1 && hasPreset && (
<div className="p-6 space-y-6 overflow-y-auto h-full">
<div className="space-y-2">
<Label htmlFor="name">{t("form.taskName")} *</Label>
<Input id="name" placeholder={t("form.taskNamePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} />
<p className="text-xs text-muted-foreground">{t("form.taskNameDesc")}</p>
</div>
<Separator />
<div className="space-y-3">
<Label>{t("form.scanTarget")}</Label>
<div className="flex items-center gap-2 p-4 border rounded-lg bg-muted/50">
<IconTarget className="h-5 w-5 text-muted-foreground" />
<span className="font-medium">{presetTargetName || presetOrganizationName}</span>
<Badge variant="secondary" className="ml-auto">
{presetTargetId ? t("form.targetScan") : t("form.organizationScan")}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{t("form.presetTargetHint")}</p>
</div>
</div>
)}
{/* Step 2: Select Target (Organization or Target) */}
{currentStep === 2 && !hasPreset && (
<div className="p-6 space-y-4 overflow-y-auto h-full">
@@ -475,8 +503,8 @@ export function CreateScheduledScanDialog({
</div>
)}
{/* Step 3 (full) / Step 1 (preset): Select Engine */}
{((currentStep === 3 && !hasPreset) || (currentStep === 1 && hasPreset)) && engines.length > 0 && (
{/* Step 3 (full) / Step 2 (preset): Select Engine */}
{((currentStep === 3 && !hasPreset) || (currentStep === 2 && hasPreset)) && engines.length > 0 && (
<EnginePresetSelector
engines={engines}
selectedEngineIds={engineIds}
@@ -488,8 +516,8 @@ export function CreateScheduledScanDialog({
/>
)}
{/* Step 4 (full) / Step 2 (preset): Edit Configuration */}
{((currentStep === 4 && !hasPreset) || (currentStep === 2 && hasPreset)) && (
{/* Step 4 (full) / Step 3 (preset): Edit Configuration */}
{((currentStep === 4 && !hasPreset) || (currentStep === 3 && hasPreset)) && (
<ScanConfigEditor
configuration={configuration}
onChange={handleManualConfigChange}
@@ -500,8 +528,8 @@ export function CreateScheduledScanDialog({
/>
)}
{/* Step 5 (full) / Step 3 (preset): Schedule Settings */}
{((currentStep === 5 && !hasPreset) || (currentStep === 3 && hasPreset)) && (
{/* Step 5 (full) / Step 4 (preset): Schedule Settings */}
{((currentStep === 5 && !hasPreset) || (currentStep === 4 && hasPreset)) && (
<div className="p-6 space-y-6 overflow-y-auto h-full">
<div className="space-y-2">
<Label>{t("form.cronExpression")} *</Label>

View File

@@ -8,11 +8,21 @@ interface AnsiLogViewerProps {
className?: string
}
// 日志级别颜色配置
const LOG_LEVEL_COLORS: Record<string, string> = {
DEBUG: "#4ec9b0", // cyan
INFO: "#6a9955", // green
WARNING: "#dcdcaa", // yellow
WARN: "#dcdcaa", // yellow
ERROR: "#f44747", // red
CRITICAL: "#f44747", // red (bold handled separately)
}
// 创建 ANSI 转换器实例
const converter = new AnsiToHtml({
const ansiConverter = new AnsiToHtml({
fg: "#d4d4d4",
bg: "#1e1e1e",
newline: true,
newline: false, // 我们自己处理换行
escapeXML: true,
colors: {
0: "#1e1e1e", // black
@@ -34,14 +44,57 @@ const converter = new AnsiToHtml({
},
})
// 检测内容是否包含 ANSI 颜色码
function hasAnsiCodes(text: string): boolean {
// ANSI 转义序列通常以 ESC[ 开头(\x1b[ 或 \u001b[
return /\x1b\[|\u001b\[/.test(text)
}
// 解析纯文本日志内容,为日志级别添加颜色
function colorizeLogContent(content: string): string {
// 匹配日志格式: [时间] [级别] [模块:行号] 消息
// 例如: [2025-01-05 10:30:00] [INFO] [apps.scan:123] 消息内容
const logLineRegex = /^(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]) (\[(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)\]) (.*)$/
return content
.split("\n")
.map((line) => {
const match = line.match(logLineRegex)
if (match) {
const [, timestamp, levelBracket, level, rest] = match
const color = LOG_LEVEL_COLORS[level] || "#d4d4d4"
// ansiConverter.toHtml 已经处理了 HTML 转义
const escapedTimestamp = ansiConverter.toHtml(timestamp)
const escapedLevelBracket = ansiConverter.toHtml(levelBracket)
const escapedRest = ansiConverter.toHtml(rest)
// 时间戳灰色,日志级别带颜色,其余默认色
return `<span style="color:#808080">${escapedTimestamp}</span> <span style="color:${color};font-weight:${level === "CRITICAL" ? "bold" : "normal"}">${escapedLevelBracket}</span> ${escapedRest}`
}
// 非标准格式的行,也进行 HTML 转义
return ansiConverter.toHtml(line)
})
.join("\n")
}
export function AnsiLogViewer({ content, className }: AnsiLogViewerProps) {
const containerRef = useRef<HTMLPreElement>(null)
const isAtBottomRef = useRef(true) // 跟踪用户是否在底部
// 将 ANSI 转换为 HTML
// 解析日志并添加颜色
// 支持两种模式ANSI 颜色码和纯文本日志级别解析
const htmlContent = useMemo(() => {
if (!content) return ""
return converter.toHtml(content)
// 如果包含 ANSI 颜色码,直接转换
if (hasAnsiCodes(content)) {
return ansiConverter.toHtml(content)
}
// 否则解析日志级别添加颜色
return colorizeLogContent(content)
}, [content])
// 监听滚动事件,检测用户是否在底部

View File

@@ -0,0 +1,359 @@
"use client"
import React, { useState } from "react"
import Link from "next/link"
import { useTranslations, useLocale } from "next-intl"
import {
Globe,
Network,
Server,
Link2,
FolderOpen,
ShieldAlert,
AlertTriangle,
Clock,
Calendar,
ChevronRight,
CheckCircle2,
PauseCircle,
Play,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { useTarget } from "@/hooks/use-targets"
import { useScheduledScans } from "@/hooks/use-scheduled-scans"
import { ScanHistoryList } from "@/components/scan/history/scan-history-list"
import { InitiateScanDialog } from "@/components/scan/initiate-scan-dialog"
import { getDateLocale } from "@/lib/date-utils"
interface TargetOverviewProps {
targetId: number
}
/**
* Target overview component
* Displays statistics cards for the target
*/
export function TargetOverview({ targetId }: TargetOverviewProps) {
const t = useTranslations("pages.targetDetail.overview")
const locale = useLocale()
const [scanDialogOpen, setScanDialogOpen] = useState(false)
const { data: target, isLoading, error } = useTarget(targetId)
const { data: scheduledScansData, isLoading: isLoadingScans } = useScheduledScans({
targetId,
pageSize: 5
})
const scheduledScans = scheduledScansData?.results || []
const totalScheduledScans = scheduledScansData?.total || 0
const enabledScans = scheduledScans.filter(s => s.isEnabled)
// Format date helper
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return "-"
return new Date(dateString).toLocaleString(getDateLocale(locale), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
// Format short date for scheduled scans
const formatShortDate = (dateString: string | undefined): string => {
if (!dateString) return "-"
const date = new Date(dateString)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
// Check if it's today
if (date.toDateString() === now.toDateString()) {
return t("scheduledScans.today") + " " + date.toLocaleTimeString(getDateLocale(locale), {
hour: "2-digit",
minute: "2-digit",
})
}
// Check if it's tomorrow
if (date.toDateString() === tomorrow.toDateString()) {
return t("scheduledScans.tomorrow") + " " + date.toLocaleTimeString(getDateLocale(locale), {
hour: "2-digit",
minute: "2-digit",
})
}
// Otherwise show date
return date.toLocaleString(getDateLocale(locale), {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
// Get next execution time from enabled scans
const getNextExecution = () => {
const enabledWithNextRun = enabledScans.filter(s => s.nextRunTime)
if (enabledWithNextRun.length === 0) return null
const sorted = enabledWithNextRun.sort((a, b) =>
new Date(a.nextRunTime!).getTime() - new Date(b.nextRunTime!).getTime()
)
return sorted[0]
}
const nextExecution = getNextExecution()
if (isLoading) {
return (
<div className="space-y-6">
{/* Stats cards skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
if (error || !target) {
return (
<div className="flex flex-col items-center justify-center py-12">
<AlertTriangle className="h-10 w-10 text-destructive mb-4" />
<p className="text-muted-foreground">{t("loadError")}</p>
</div>
)
}
const summary = (target as any).summary || {}
const vulnSummary = summary.vulnerabilities || { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
const assetCards = [
{
title: t("cards.websites"),
value: summary.websites || 0,
icon: Globe,
href: `/target/${targetId}/websites/`,
},
{
title: t("cards.subdomains"),
value: summary.subdomains || 0,
icon: Network,
href: `/target/${targetId}/subdomain/`,
},
{
title: t("cards.ips"),
value: summary.ips || 0,
icon: Server,
href: `/target/${targetId}/ip-addresses/`,
},
{
title: t("cards.urls"),
value: summary.endpoints || 0,
icon: Link2,
href: `/target/${targetId}/endpoints/`,
},
{
title: t("cards.directories"),
value: summary.directories || 0,
icon: FolderOpen,
href: `/target/${targetId}/directories/`,
},
]
return (
<div className="space-y-6">
{/* Target info + Initiate Scan button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>{t("createdAt")}: {formatDate(target.createdAt)}</span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
<span>{t("lastScanned")}: {formatDate(target.lastScannedAt)}</span>
</div>
</div>
<Button onClick={() => setScanDialogOpen(true)}>
<Play className="h-4 w-4 mr-2" />
{t("initiateScan")}
</Button>
</div>
{/* Asset statistics cards */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("assetsTitle")}</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{assetCards.map((card) => (
<Link key={card.title} href={card.href}>
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<card.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value.toLocaleString()}</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
{/* Scheduled Scans + Vulnerability Statistics (Two columns) */}
<div className="grid gap-4 md:grid-cols-2">
{/* Scheduled Scans Card */}
<Card className="flex flex-col">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">{t("scheduledScans.title")}</CardTitle>
</div>
<Link href={`/target/${targetId}/settings/`}>
<Button variant="ghost" size="sm" className="h-7 text-xs">
{t("scheduledScans.manage")}
<ChevronRight className="h-3 w-3 ml-1" />
</Button>
</Link>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
{isLoadingScans ? (
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
</div>
) : totalScheduledScans === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center">
<Clock className="h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">{t("scheduledScans.empty")}</p>
<Link href={`/target/${targetId}/settings/`}>
<Button variant="link" size="sm" className="mt-1">
{t("scheduledScans.createFirst")}
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{/* Stats row */}
<div className="flex items-center gap-4 text-sm">
<div>
<span className="text-muted-foreground">{t("scheduledScans.configured")}: </span>
<span className="font-medium">{totalScheduledScans}</span>
</div>
<div>
<span className="text-muted-foreground">{t("scheduledScans.enabled")}: </span>
<span className="font-medium text-green-600">{enabledScans.length}</span>
</div>
</div>
{/* Next execution */}
{nextExecution && (
<div className="text-sm">
<span className="text-muted-foreground">{t("scheduledScans.nextRun")}: </span>
<span className="font-medium">{formatShortDate(nextExecution.nextRunTime)}</span>
</div>
)}
{/* Task list - max 2 items */}
<div className="space-y-2 pt-2 border-t">
{scheduledScans.slice(0, 2).map((scan) => (
<div key={scan.id} className="flex items-center gap-2 text-sm">
{scan.isEnabled ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : (
<PauseCircle className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<span className={`truncate ${!scan.isEnabled ? 'text-muted-foreground' : ''}`}>
{scan.name}
</span>
</div>
))}
{totalScheduledScans > 2 && (
<p className="text-xs text-muted-foreground">
{t("scheduledScans.more", { count: totalScheduledScans - 2 })}
</p>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* Vulnerability Statistics Card */}
<Link href={`/target/${targetId}/vulnerabilities/`} className="block">
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer flex flex-col">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-red-500" />
<CardTitle className="text-sm font-medium">{t("vulnerabilitiesTitle")}</CardTitle>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs">
{t("viewAll")}
<ChevronRight className="h-3 w-3 ml-1" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* Total count */}
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{vulnSummary.total}</span>
<span className="text-sm text-muted-foreground">{t("cards.vulnerabilities")}</span>
</div>
{/* Severity breakdown */}
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm text-muted-foreground">{t("severity.critical")}</span>
<span className="text-sm font-medium ml-auto">{vulnSummary.critical}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500" />
<span className="text-sm text-muted-foreground">{t("severity.high")}</span>
<span className="text-sm font-medium ml-auto">{vulnSummary.high}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-sm text-muted-foreground">{t("severity.medium")}</span>
<span className="text-sm font-medium ml-auto">{vulnSummary.medium}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-sm text-muted-foreground">{t("severity.low")}</span>
<span className="text-sm font-medium ml-auto">{vulnSummary.low}</span>
</div>
</div>
</CardContent>
</Card>
</Link>
</div>
{/* Scan history */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("scanHistoryTitle")}</h3>
<ScanHistoryList targetId={targetId} hideToolbar pageSize={5} hideTargetColumn pageSizeOptions={[5, 10, 20, 50, 100]} />
</div>
{/* Initiate Scan Dialog */}
<InitiateScanDialog
open={scanDialogOpen}
onOpenChange={setScanDialogOpen}
targetId={targetId}
targetName={target.name}
/>
</div>
)
}

View File

@@ -0,0 +1,369 @@
"use client"
import React, { useState, useEffect } from "react"
import { useTranslations, useLocale } from "next-intl"
import { AlertTriangle, Loader2, Ban, Clock } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useTargetBlacklist, useUpdateTargetBlacklist, useTarget } from "@/hooks/use-targets"
import { useScheduledScans, useToggleScheduledScan, useDeleteScheduledScan } from "@/hooks/use-scheduled-scans"
import { ScheduledScanDataTable } from "@/components/scan/scheduled/scheduled-scan-data-table"
import { createScheduledScanColumns } from "@/components/scan/scheduled/scheduled-scan-columns"
import { CreateScheduledScanDialog } from "@/components/scan/scheduled/create-scheduled-scan-dialog"
import { EditScheduledScanDialog } from "@/components/scan/scheduled/edit-scheduled-scan-dialog"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import type { ScheduledScan } from "@/types/scheduled-scan.types"
interface TargetSettingsProps {
targetId: number
}
/**
* Target settings component
* Contains blacklist configuration and scheduled scans
*/
export function TargetSettings({ targetId }: TargetSettingsProps) {
const t = useTranslations("pages.targetDetail.settings")
const tColumns = useTranslations("columns")
const tCommon = useTranslations("common")
const tScan = useTranslations("scan")
const tConfirm = useTranslations("common.confirm")
const locale = useLocale()
const [blacklistText, setBlacklistText] = useState("")
const [hasChanges, setHasChanges] = useState(false)
// Scheduled scan states
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [editingScheduledScan, setEditingScheduledScan] = useState<ScheduledScan | null>(null)
const [deletingScheduledScan, setDeletingScheduledScan] = useState<ScheduledScan | null>(null)
// Pagination state
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [searchQuery, setSearchQuery] = useState("")
const [isSearching, setIsSearching] = useState(false)
// Fetch target data for preset name
const { data: target } = useTarget(targetId)
// Fetch blacklist data
const { data, isLoading, error } = useTargetBlacklist(targetId)
const updateBlacklist = useUpdateTargetBlacklist()
// Fetch scheduled scans for this target
const {
data: scheduledScansData,
isLoading: isLoadingScans,
isFetching,
refetch
} = useScheduledScans({
targetId,
page,
pageSize,
search: searchQuery || undefined
})
const { mutate: toggleScheduledScan } = useToggleScheduledScan()
const { mutate: deleteScheduledScan } = useDeleteScheduledScan()
const scheduledScans = scheduledScansData?.results || []
const total = scheduledScansData?.total || 0
const totalPages = scheduledScansData?.totalPages || 1
// Build translation object for columns
const translations = React.useMemo(() => ({
columns: {
taskName: tColumns("scheduledScan.taskName"),
scanEngine: tColumns("scheduledScan.scanEngine"),
cronExpression: tColumns("scheduledScan.cronExpression"),
scope: tColumns("scheduledScan.scope"),
status: tColumns("common.status"),
nextRun: tColumns("scheduledScan.nextRun"),
runCount: tColumns("scheduledScan.runCount"),
lastRun: tColumns("scheduledScan.lastRun"),
},
actions: {
editTask: tScan("editTask"),
delete: tCommon("actions.delete"),
openMenu: tCommon("actions.openMenu"),
},
status: {
enabled: tCommon("status.enabled"),
disabled: tCommon("status.disabled"),
},
cron: {
everyMinute: tScan("cron.everyMinute"),
everyNMinutes: tScan.raw("cron.everyNMinutes") as string,
everyHour: tScan.raw("cron.everyHour") as string,
everyNHours: tScan.raw("cron.everyNHours") as string,
everyDay: tScan.raw("cron.everyDay") as string,
everyWeek: tScan.raw("cron.everyWeek") as string,
everyMonth: tScan.raw("cron.everyMonth") as string,
weekdays: tScan.raw("cron.weekdays") as string[],
},
}), [tColumns, tCommon, tScan])
// Initialize text when data loads
useEffect(() => {
if (data?.patterns) {
setBlacklistText(data.patterns.join("\n"))
setHasChanges(false)
}
}, [data])
// Reset search state when request completes
useEffect(() => {
if (!isFetching && isSearching) {
setIsSearching(false)
}
}, [isFetching, isSearching])
// Handle text change
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBlacklistText(e.target.value)
setHasChanges(true)
}
// Handle save
const handleSave = () => {
const patterns = blacklistText
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
updateBlacklist.mutate(
{ targetId, patterns },
{
onSuccess: () => {
setHasChanges(false)
},
}
)
}
// Format date
const formatDate = React.useCallback((dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString(locale === "zh" ? "zh-CN" : "en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}, [locale])
// Edit task
const handleEdit = React.useCallback((scan: ScheduledScan) => {
setEditingScheduledScan(scan)
setEditDialogOpen(true)
}, [])
// Delete task (open confirmation dialog)
const handleDelete = React.useCallback((scan: ScheduledScan) => {
setDeletingScheduledScan(scan)
setDeleteDialogOpen(true)
}, [])
// Confirm delete task
const confirmDelete = React.useCallback(() => {
if (deletingScheduledScan) {
deleteScheduledScan(deletingScheduledScan.id)
setDeleteDialogOpen(false)
setDeletingScheduledScan(null)
}
}, [deletingScheduledScan, deleteScheduledScan])
// Toggle task enabled status
const handleToggleStatus = React.useCallback((scan: ScheduledScan, enabled: boolean) => {
toggleScheduledScan({ id: scan.id, isEnabled: enabled })
}, [toggleScheduledScan])
// Search handler
const handleSearchChange = (value: string) => {
setIsSearching(true)
setSearchQuery(value)
setPage(1)
}
// Page change handler
const handlePageChange = React.useCallback((newPage: number) => {
setPage(newPage)
}, [])
// Page size change handler
const handlePageSizeChange = React.useCallback((newPageSize: number) => {
setPageSize(newPageSize)
setPage(1)
}, [])
// Add new task
const handleAddNew = React.useCallback(() => {
setCreateDialogOpen(true)
}, [])
// Create column definition (hide scope column since we're filtering by target)
const columns = React.useMemo(() => {
const allColumns = createScheduledScanColumns({
formatDate,
handleEdit,
handleDelete,
handleToggleStatus,
t: translations,
})
// Filter out the scope column since all scans are for this target
return allColumns.filter(col => (col as { accessorKey?: string }).accessorKey !== 'scanMode')
}, [formatDate, handleEdit, handleDelete, handleToggleStatus, translations])
if (isLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-48 w-full" />
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
<AlertTriangle className="h-10 w-10 text-destructive mb-4" />
<p className="text-muted-foreground">{t("loadError")}</p>
</div>
)
}
return (
<div className="space-y-6">
{/* Blacklist section */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Ban className="h-5 w-5 text-muted-foreground" />
<CardTitle>{t("blacklist.title")}</CardTitle>
</div>
<CardDescription>{t("blacklist.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Rules hint */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{t("blacklist.rulesTitle")}:</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">*.gov</code> {t("blacklist.rules.domainShort")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">*cdn*</code> {t("blacklist.rules.keywordShort")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">192.168.1.1</code> {t("blacklist.rules.ipShort")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">10.0.0.0/8</code> {t("blacklist.rules.cidrShort")}</span>
</div>
{/* Input */}
<Textarea
value={blacklistText}
onChange={handleTextChange}
placeholder={t("blacklist.placeholder")}
className="min-h-[240px] font-mono text-sm"
/>
{/* Save button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!hasChanges || updateBlacklist.isPending}
>
{updateBlacklist.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("blacklist.save")}
</Button>
</div>
</CardContent>
</Card>
{/* Scheduled Scans section */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle>{t("scheduledScans.title")}</CardTitle>
</div>
<CardDescription>{t("scheduledScans.description")}</CardDescription>
</CardHeader>
<CardContent>
{isLoadingScans ? (
<DataTableSkeleton rows={3} columns={6} toolbarButtonCount={1} />
) : (
<ScheduledScanDataTable
data={scheduledScans}
columns={columns}
onAddNew={handleAddNew}
searchPlaceholder={tScan("scheduled.searchPlaceholder")}
searchValue={searchQuery}
onSearch={handleSearchChange}
isSearching={isSearching}
addButtonText={tScan("scheduled.createTitle")}
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<CreateScheduledScanDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
presetTargetId={targetId}
presetTargetName={target?.name}
onSuccess={() => refetch()}
/>
{/* Edit Dialog */}
<EditScheduledScanDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
scheduledScan={editingScheduledScan}
onSuccess={() => refetch()}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{tConfirm("deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{tConfirm("deleteScheduledScanMessage", { name: deletingScheduledScan?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("actions.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -18,15 +18,22 @@ function Tabs({
)
}
interface TabsListProps extends React.ComponentProps<typeof TabsPrimitive.List> {
variant?: "default" | "underline"
}
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
}: TabsListProps) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
"inline-flex w-fit items-center justify-center",
variant === "default" && "bg-muted text-muted-foreground h-9 rounded-lg p-[3px]",
variant === "underline" && "h-10 gap-4 border-b border-border bg-transparent p-0",
className
)}
{...props}
@@ -34,15 +41,22 @@ function TabsList({
)
}
interface TabsTriggerProps extends React.ComponentProps<typeof TabsPrimitive.Trigger> {
variant?: "default" | "underline"
}
function TabsTrigger({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
}: TabsTriggerProps) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-zinc-500 dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap cursor-pointer transition-[color,box-shadow] focus-visible:ring-[1px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap cursor-pointer transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variant === "default" && "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-zinc-500 dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground h-[calc(100%-1px)] flex-1 rounded-md border border-transparent px-2 py-1 focus-visible:ring-[1px] focus-visible:outline-1 data-[state=active]:shadow-sm",
variant === "underline" && "text-muted-foreground data-[state=active]:text-foreground h-10 px-1 pb-3 -mb-px border-b-2 border-transparent data-[state=active]:border-primary rounded-none bg-transparent",
className
)}
{...props}

View File

@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useTranslations } from 'next-intl'
import {
getGlobalBlacklist,
updateGlobalBlacklist,
type GlobalBlacklistResponse,
type UpdateGlobalBlacklistRequest,
} from '@/services/global-blacklist.service'
const QUERY_KEY = ['global-blacklist']
/**
* Hook to fetch global blacklist
*/
export function useGlobalBlacklist() {
return useQuery<GlobalBlacklistResponse>({
queryKey: QUERY_KEY,
queryFn: getGlobalBlacklist,
})
}
/**
* Hook to update global blacklist
*/
export function useUpdateGlobalBlacklist() {
const queryClient = useQueryClient()
const t = useTranslations('pages.settings.blacklist')
return useMutation({
mutationFn: (data: UpdateGlobalBlacklistRequest) => updateGlobalBlacklist(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
toast.success(t('toast.saveSuccess'))
},
onError: () => {
toast.error(t('toast.saveError'))
},
})
}

View File

@@ -29,6 +29,17 @@ export function useRunningScans(page = 1, pageSize = 10) {
return useScans({ page, pageSize, status: 'running' })
}
/**
* 获取目标的扫描历史
*/
export function useTargetScans(targetId: number, pageSize = 5) {
return useQuery({
queryKey: ['scans', 'target', targetId, pageSize],
queryFn: () => getScans({ target: targetId, pageSize }),
enabled: !!targetId,
})
}
export function useScan(id: number) {
return useQuery({
queryKey: ['scan', id],

View File

@@ -14,7 +14,13 @@ import type { CreateScheduledScanRequest, UpdateScheduledScanRequest } from '@/t
/**
* 获取定时扫描列表
*/
export function useScheduledScans(params: { page?: number; pageSize?: number; search?: string } = { page: 1, pageSize: 10 }) {
export function useScheduledScans(params: {
page?: number
pageSize?: number
search?: string
targetId?: number
organizationId?: number
} = { page: 1, pageSize: 10 }) {
return useQuery({
queryKey: ['scheduled-scans', params],
queryFn: () => getScheduledScans(params),

View File

@@ -16,6 +16,8 @@ import {
linkTargetOrganizations,
unlinkTargetOrganizations,
getTargetEndpoints,
getTargetBlacklist,
updateTargetBlacklist,
} from '@/services/target.service'
import type {
CreateTargetRequest,
@@ -304,3 +306,34 @@ export function useTargetEndpoints(
})
}
/**
* 获取目标的黑名单规则
*/
export function useTargetBlacklist(targetId: number) {
return useQuery({
queryKey: ['targets', targetId, 'blacklist'],
queryFn: () => getTargetBlacklist(targetId),
enabled: !!targetId,
})
}
/**
* 更新目标的黑名单规则
*/
export function useUpdateTargetBlacklist() {
const queryClient = useQueryClient()
const toastMessages = useToastMessages()
return useMutation({
mutationFn: ({ targetId, patterns }: { targetId: number; patterns: string[] }) =>
updateTargetBlacklist(targetId, patterns),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['targets', variables.targetId, 'blacklist'] })
toastMessages.success('toast.blacklist.save.success')
},
onError: (error: any) => {
toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.blacklist.save.error')
},
})
}

View File

@@ -320,6 +320,7 @@
"systemLogs": "System Logs",
"notifications": "Notifications",
"apiKeys": "API Keys",
"globalBlacklist": "Global Blacklist",
"help": "Get Help",
"feedback": "Feedback"
},
@@ -837,7 +838,9 @@
"noConfig": "No config",
"capabilitiesCount": "{count} capabilities",
"selected": "Selected",
"selectedEngines": "{count} engines selected"
"selectedEngines": "{count} engines selected",
"scanTarget": "Scan Target",
"presetTargetHint": "Target is preset and cannot be changed. To scan other targets, create from the global scheduled scans page."
},
"presets": {
"everyHour": "Every Hour",
@@ -1766,6 +1769,12 @@
"error": "Failed to fetch system logs, please check backend",
"recovered": "System log connection recovered"
}
},
"blacklist": {
"save": {
"success": "Blacklist rules saved",
"error": "Failed to save blacklist rules"
}
}
},
"quickScan": {
@@ -2012,6 +2021,9 @@
},
"targetDetail": {
"noDescription": "No description",
"breadcrumb": {
"targetDetail": "Target Detail"
},
"error": {
"title": "Load Failed",
"message": "An error occurred while fetching target data"
@@ -2019,11 +2031,138 @@
"notFound": {
"title": "Target Not Found",
"message": "Target with ID {id} not found"
},
"tabs": {
"overview": "Overview",
"assets": "Assets",
"vulnerabilities": "Vulnerabilities",
"settings": "Settings"
},
"settings": {
"loadError": "Failed to load settings",
"blacklist": {
"title": "Blacklist Rules",
"description": "Assets matching the following rules will be automatically excluded during scanning.",
"rulesTitle": "Supported Rule Types",
"rules": {
"domain": "Domain wildcard, matches specified suffix",
"domainShort": "Domain",
"keyword": "Keyword match, contains specified string",
"keywordShort": "Keyword",
"ip": "Exact IP address match",
"ipShort": "IP",
"cidr": "Matches IP range",
"cidrShort": "CIDR"
},
"placeholder": "Enter rules, one per line\n\nExamples:\n*.gov\n*.edu\n*cdn*\n192.168.0.0/16\n10.0.0.1",
"save": "Save Rules"
},
"scheduledScans": {
"title": "Scheduled Scans",
"description": "Configure automated scan tasks for this target",
"create": "New Scheduled Scan",
"empty": "No scheduled scans",
"emptyHint": "Click the button above to create a scheduled scan",
"enabled": "Enabled",
"disabled": "Disabled",
"nextRun": "Next run",
"runCount": "Run count",
"edit": "Edit",
"delete": "Delete",
"cronDaily": "Daily at {time}",
"cronWeekly": "Every {day} at {time}",
"cronMonthly": "Monthly on day {day} at {time}",
"weekdays": {
"sun": "Sunday",
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday"
},
"deleteConfirm": {
"title": "Confirm Delete",
"description": "Are you sure you want to delete the scheduled scan \"{name}\"? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete"
}
}
},
"overview": {
"loadError": "Failed to load target data",
"createdAt": "Created",
"lastScanned": "Last Scanned",
"assetsTitle": "Assets",
"vulnerabilitiesTitle": "Vulnerabilities",
"scanHistoryTitle": "Scan History",
"recentScans": "Recent Scans",
"noScans": "No scan records",
"viewAll": "View all",
"cards": {
"websites": "Websites",
"subdomains": "Subdomains",
"ips": "IP Addresses",
"urls": "URLs",
"directories": "Directories",
"vulnerabilities": "Total Vulnerabilities"
},
"severity": {
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
},
"scanStatus": {
"completed": "Completed",
"running": "Running",
"failed": "Failed",
"cancelled": "Cancelled",
"initiated": "Pending"
},
"scheduledScans": {
"title": "Scheduled Scans",
"manage": "Manage",
"empty": "No scheduled scans",
"createFirst": "Create your first scheduled scan",
"configured": "Configured",
"enabled": "Enabled",
"nextRun": "Next run",
"today": "Today",
"tomorrow": "Tomorrow",
"more": "+{count} more"
},
"initiateScan": "Initiate Scan"
}
},
"nav": {
"scanEngine": "Scan Engine",
"wordlists": "Wordlist Management"
},
"settings": {
"blacklist": {
"title": "Global Blacklist",
"description": "Configure global blacklist rules. Matching assets will be automatically excluded during scans.",
"loadError": "Failed to load blacklist rules",
"card": {
"title": "Blacklist Rules",
"description": "These rules apply to all target scans. To configure blacklist for a specific target, go to the target settings page."
},
"rules": {
"title": "Supported rule types",
"domain": "Domain",
"keyword": "Keyword",
"ip": "IP",
"cidr": "CIDR"
},
"scopeHint": "Global rules apply to all targets. Target-level rules can be configured in Target → Settings.",
"placeholder": "Enter rules, one per line\n\nExamples:\n*.gov\n*.edu\n*cdn*\n192.168.0.0/16\n10.0.0.1",
"save": "Save Rules",
"toast": {
"saveSuccess": "Blacklist rules saved",
"saveError": "Failed to save blacklist rules"
}
}
}
},
"metadata": {

View File

@@ -320,6 +320,7 @@
"systemLogs": "系统日志",
"notifications": "通知设置",
"apiKeys": "API 密钥",
"globalBlacklist": "全局黑名单",
"help": "获取帮助",
"feedback": "反馈建议"
},
@@ -837,7 +838,9 @@
"noConfig": "无配置",
"capabilitiesCount": "{count} 项能力",
"selected": "已选择",
"selectedEngines": "已选择 {count} 个引擎"
"selectedEngines": "已选择 {count} 个引擎",
"scanTarget": "扫描目标",
"presetTargetHint": "目标已预设,无法更改。如需扫描其他目标,请从全局定时扫描页面创建。"
},
"presets": {
"everyHour": "每小时",
@@ -1766,6 +1769,12 @@
"error": "系统日志获取失败,请检查后端接口",
"recovered": "系统日志连接已恢复"
}
},
"blacklist": {
"save": {
"success": "黑名单规则已保存",
"error": "保存黑名单规则失败"
}
}
},
"quickScan": {
@@ -2012,6 +2021,9 @@
},
"targetDetail": {
"noDescription": "暂无描述",
"breadcrumb": {
"targetDetail": "目标详情"
},
"error": {
"title": "加载失败",
"message": "获取目标数据时出现错误"
@@ -2019,11 +2031,138 @@
"notFound": {
"title": "目标不存在",
"message": "未找到ID为 {id} 的目标"
},
"tabs": {
"overview": "概览",
"assets": "资产",
"vulnerabilities": "漏洞",
"settings": "设置"
},
"settings": {
"loadError": "加载设置失败",
"blacklist": {
"title": "黑名单规则",
"description": "扫描时将自动排除匹配以下规则的资产。",
"rulesTitle": "支持的规则类型",
"rules": {
"domain": "域名通配符,匹配指定后缀",
"domainShort": "域名",
"keyword": "关键词匹配,包含指定字符串",
"keywordShort": "关键词",
"ip": "精确匹配 IP 地址",
"ipShort": "IP",
"cidr": "匹配 IP 网段范围",
"cidrShort": "CIDR"
},
"placeholder": "输入规则,每行一个\n\n示例\n*.gov\n*.edu\n*cdn*\n192.168.0.0/16\n10.0.0.1",
"save": "保存规则"
},
"scheduledScans": {
"title": "定时扫描",
"description": "为该目标配置自动执行的扫描任务",
"create": "新建定时扫描",
"empty": "暂无定时扫描任务",
"emptyHint": "点击上方按钮创建定时扫描任务",
"enabled": "已启用",
"disabled": "已禁用",
"nextRun": "下次执行",
"runCount": "执行次数",
"edit": "编辑",
"delete": "删除",
"cronDaily": "每天 {time}",
"cronWeekly": "每周{day} {time}",
"cronMonthly": "每月{day}日 {time}",
"weekdays": {
"sun": "日",
"mon": "一",
"tue": "二",
"wed": "三",
"thu": "四",
"fri": "五",
"sat": "六"
},
"deleteConfirm": {
"title": "确认删除",
"description": "确定要删除定时扫描任务「{name}」吗?此操作无法撤销。",
"cancel": "取消",
"confirm": "删除"
}
}
},
"overview": {
"loadError": "加载目标数据失败",
"createdAt": "创建时间",
"lastScanned": "最后扫描",
"assetsTitle": "资产统计",
"vulnerabilitiesTitle": "漏洞统计",
"scanHistoryTitle": "扫描历史",
"recentScans": "最近扫描",
"noScans": "暂无扫描记录",
"viewAll": "查看全部",
"cards": {
"websites": "网站",
"subdomains": "子域名",
"ips": "IP 地址",
"urls": "URL",
"directories": "目录",
"vulnerabilities": "漏洞总数"
},
"severity": {
"critical": "严重",
"high": "高危",
"medium": "中危",
"low": "低危"
},
"scanStatus": {
"completed": "已完成",
"running": "运行中",
"failed": "失败",
"cancelled": "已取消",
"initiated": "等待中"
},
"scheduledScans": {
"title": "定时扫描",
"manage": "管理",
"empty": "暂无定时扫描任务",
"createFirst": "创建第一个定时扫描",
"configured": "已配置",
"enabled": "已启用",
"nextRun": "下次执行",
"today": "今天",
"tomorrow": "明天",
"more": "+{count} 更多"
},
"initiateScan": "发起扫描"
}
},
"nav": {
"scanEngine": "扫描引擎",
"wordlists": "字典管理"
},
"settings": {
"blacklist": {
"title": "全局黑名单",
"description": "配置全局黑名单规则,扫描时将自动排除匹配的资产",
"loadError": "加载黑名单规则失败",
"card": {
"title": "黑名单规则",
"description": "这些规则将应用于所有目标的扫描任务。如需为特定目标配置黑名单,请前往目标设置页面。"
},
"rules": {
"title": "支持的规则类型",
"domain": "域名",
"keyword": "关键词",
"ip": "IP",
"cidr": "CIDR"
},
"scopeHint": "全局规则对所有目标生效。目标级规则可在「目标 → 设置」中单独配置。",
"placeholder": "输入规则,每行一个\n\n示例\n*.gov\n*.edu\n*cdn*\n192.168.0.0/16\n10.0.0.1",
"save": "保存规则",
"toast": {
"saveSuccess": "黑名单规则已保存",
"saveError": "保存黑名单规则失败"
}
}
}
},
"metadata": {

View File

@@ -0,0 +1,25 @@
import { api } from '@/lib/api-client'
export interface GlobalBlacklistResponse {
patterns: string[]
}
export interface UpdateGlobalBlacklistRequest {
patterns: string[]
}
/**
* Get global blacklist rules
*/
export async function getGlobalBlacklist(): Promise<GlobalBlacklistResponse> {
const res = await api.get<GlobalBlacklistResponse>('/blacklist/rules/')
return res.data
}
/**
* Update global blacklist rules (full replace)
*/
export async function updateGlobalBlacklist(data: UpdateGlobalBlacklistRequest): Promise<GlobalBlacklistResponse> {
const res = await api.put<GlobalBlacklistResponse>('/blacklist/rules/', data)
return res.data
}

View File

@@ -10,12 +10,26 @@ import { USE_MOCK, mockDelay, getMockScheduledScans, getMockScheduledScanById }
/**
* Get scheduled scan list
*/
export async function getScheduledScans(params?: { page?: number; pageSize?: number; search?: string }): Promise<GetScheduledScansResponse> {
export async function getScheduledScans(params?: {
page?: number
pageSize?: number
search?: string
targetId?: number
organizationId?: number
}): Promise<GetScheduledScansResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockScheduledScans(params)
}
const res = await api.get<GetScheduledScansResponse>('/scheduled-scans/', { params })
// Convert camelCase to snake_case for query params (djangorestframework-camel-case doesn't convert query params)
const apiParams: Record<string, unknown> = {}
if (params?.page) apiParams.page = params.page
if (params?.pageSize) apiParams.pageSize = params.pageSize
if (params?.search) apiParams.search = params.search
if (params?.targetId) apiParams.target_id = params.targetId
if (params?.organizationId) apiParams.organization_id = params.organizationId
const res = await api.get<GetScheduledScansResponse>('/scheduled-scans/', { params: apiParams })
return res.data
}

View File

@@ -159,3 +159,22 @@ export async function getTargetEndpoints(
return response.data
}
/**
* Get target's blacklist rules
*/
export async function getTargetBlacklist(id: number): Promise<{ patterns: string[] }> {
const response = await api.get<{ patterns: string[] }>(`/targets/${id}/blacklist/`)
return response.data
}
/**
* Update target's blacklist rules (full replace)
*/
export async function updateTargetBlacklist(
id: number,
patterns: string[]
): Promise<{ count: number }> {
const response = await api.put<{ count: number }>(`/targets/${id}/blacklist/`, { patterns })
return response.data
}

View File

@@ -66,6 +66,7 @@ export interface GetScansParams {
pageSize?: number
status?: ScanStatus
search?: string
target?: number // Filter by target ID
}
export interface GetScansResponse {