Compare commits

..

14 Commits

Author SHA1 Message Date
yyhuni
cd5c2b9f11 chore(notifications): remove test notification endpoint
- Remove test notification route from URL patterns
- Delete notifications_test view function and associated logic
- Clean up unused test endpoint that was used for development purposes
- Simplify notification API surface by removing non-production code
2026-01-06 16:57:29 +08:00
yyhuni
54786c22dd feat(scan): increase max batch size for quick scan operations
- Increase MAX_BATCH_SIZE from 1000 to 5000 in QuickScanSerializer
- Allows processing of larger batch scans in a single operation
- Improves throughput for bulk scanning workflows
2026-01-06 16:55:28 +08:00
yyhuni
d468f975ab feat(scan): implement fallback chain for endpoint URL export
- Add fallback chain for URL data sources: Endpoint → WebSite → default generation
- Import WebSite model and Path utility for enhanced file handling
- Create output directory automatically if it doesn't exist
- Add "source" field to return value indicating data origin (endpoint/website/default)
- Update docstring to document the three-tier fallback priority system
- Implement sequential export attempts with logging at each fallback stage
- Improve error handling and data source transparency for endpoint exports
2026-01-06 16:30:42 +08:00
yyhuni
a85a12b8ad feat(asset): create incremental materialized views for asset search
- Add pg_ivm extension for incremental materialized view maintenance
- Create asset_search_view for Website model with optimized columns for full-text search
- Create endpoint_search_view for Endpoint model with matching search schema
- Add database indexes on host, url, title, status_code, and created_at columns for both views
- Enable high-performance asset search queries with automatic view maintenance
2026-01-06 16:22:24 +08:00
yyhuni
a8b0d97b7b feat(targets): update navigation routes and enhance add button UI
- Change target detail navigation route from `/website/` to `/overview/`
- Update TargetNameCell click handler to use new overview route
- Update TargetRowActions onView handler to use new overview route
- Add IconPlus icon import from @tabler/icons-react
- Add icon to create target button for improved visual clarity
- Improves navigation consistency and button affordance in targets table
2026-01-06 16:14:54 +08:00
yyhuni
b8504921c2 feat(fingerprints): add JSONL format support for Goby fingerprint imports
- Add support for JSONL format parsing in addition to standard JSON for Goby fingerprints
- Update GobyFingerprintService to validate both standard format (name/logic/rule) and JSONL format (product/rule)
- Implement _parse_json_content() method to handle both JSON and JSONL file formats with proper error handling
- Add JSONL parsing logic in frontend import dialog with per-line validation and error reporting
- Update file import endpoint documentation to indicate JSONL format support
- Improve error messages for encoding and parsing failures to aid user debugging
- Enable seamless import of Goby fingerprint data from multiple source formats
2026-01-06 16:10:14 +08:00
yyhuni
ecfc1822fb style(target): update vulnerability icon color to muted foreground
- Change ShieldAlert icon color from red-500 to muted-foreground in target overview
- Improves visual consistency with design system color palette
- Reduces visual emphasis on vulnerability section for better UI balance
2026-01-06 12:01:59 +08:00
github-actions[bot]
81633642e6 chore: bump version to v1.3.16-dev 2026-01-06 03:55:16 +00:00
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
75 changed files with 2866 additions and 661 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.16-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,104 @@
"""
创建资产搜索物化视图(使用 pg_ivm 增量维护)
这些视图用于资产搜索功能,提供高性能的全文搜索能力。
"""
from django.db import migrations
class Migration(migrations.Migration):
"""创建资产搜索所需的增量物化视图"""
dependencies = [
('asset', '0001_initial'),
]
operations = [
# 1. 确保 pg_ivm 扩展已安装
migrations.RunSQL(
sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;",
reverse_sql="DROP EXTENSION IF EXISTS pg_ivm;",
),
# 2. 创建 Website 搜索视图
# 注意pg_ivm 不支持 ArrayField所以 tech 字段需要从原表 JOIN 获取
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="DROP TABLE IF EXISTS asset_search_view CASCADE;",
),
# 3. 创建 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="DROP TABLE IF EXISTS endpoint_search_view CASCADE;",
),
# 4. 为搜索视图创建索引(加速查询)
migrations.RunSQL(
sql=[
# Website 搜索视图索引
"CREATE INDEX IF NOT EXISTS asset_search_view_host_idx ON asset_search_view (host);",
"CREATE INDEX IF NOT EXISTS asset_search_view_url_idx ON asset_search_view (url);",
"CREATE INDEX IF NOT EXISTS asset_search_view_title_idx ON asset_search_view (title);",
"CREATE INDEX IF NOT EXISTS asset_search_view_status_idx ON asset_search_view (status_code);",
"CREATE INDEX IF NOT EXISTS asset_search_view_created_idx ON asset_search_view (created_at DESC);",
# Endpoint 搜索视图索引
"CREATE INDEX IF NOT EXISTS endpoint_search_view_host_idx ON endpoint_search_view (host);",
"CREATE INDEX IF NOT EXISTS endpoint_search_view_url_idx ON endpoint_search_view (url);",
"CREATE INDEX IF NOT EXISTS endpoint_search_view_title_idx ON endpoint_search_view (title);",
"CREATE INDEX IF NOT EXISTS endpoint_search_view_status_idx ON endpoint_search_view (status_code);",
"CREATE INDEX IF NOT EXISTS endpoint_search_view_created_idx ON endpoint_search_view (created_at DESC);",
],
reverse_sql=[
"DROP INDEX IF EXISTS asset_search_view_host_idx;",
"DROP INDEX IF EXISTS asset_search_view_url_idx;",
"DROP INDEX IF EXISTS asset_search_view_title_idx;",
"DROP INDEX IF EXISTS asset_search_view_status_idx;",
"DROP INDEX IF EXISTS asset_search_view_created_idx;",
"DROP INDEX IF EXISTS endpoint_search_view_host_idx;",
"DROP INDEX IF EXISTS endpoint_search_view_url_idx;",
"DROP INDEX IF EXISTS endpoint_search_view_title_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

@@ -16,10 +16,9 @@ class GobyFingerprintService(BaseFingerprintService):
"""
校验单条 Goby 指纹
校验规则
- name 字段必须存在且非空
- logic 字段必须存在
- rule 字段必须是数组
支持两种格式
1. 标准格式: {"name": "...", "logic": "...", "rule": [...]}
2. JSONL 格式: {"product": "...", "rule": "..."}
Args:
item: 单条指纹数据
@@ -27,25 +26,43 @@ class GobyFingerprintService(BaseFingerprintService):
Returns:
bool: 是否有效
"""
# 标准格式name + logic + rule(数组)
name = item.get('name', '')
logic = item.get('logic', '')
rule = item.get('rule')
return bool(name and str(name).strip()) and bool(logic) and isinstance(rule, list)
if name and item.get('logic') is not None and isinstance(item.get('rule'), list):
return bool(str(name).strip())
# JSONL 格式product + rule(字符串)
product = item.get('product', '')
rule = item.get('rule', '')
return bool(product and str(product).strip() and rule and str(rule).strip())
def to_model_data(self, item: dict) -> dict:
"""
转换 Goby JSON 格式为 Model 字段
支持两种输入格式:
1. 标准格式: {"name": "...", "logic": "...", "rule": [...]}
2. JSONL 格式: {"product": "...", "rule": "..."}
Args:
item: 原始 Goby JSON 数据
Returns:
dict: Model 字段数据
"""
# 标准格式
if 'name' in item and isinstance(item.get('rule'), list):
return {
'name': str(item.get('name', '')).strip(),
'logic': item.get('logic', ''),
'rule': item.get('rule', []),
}
# JSONL 格式:将 rule 字符串转为单元素数组
return {
'name': str(item.get('name', '')).strip(),
'logic': item.get('logic', ''),
'rule': item.get('rule', []),
'name': str(item.get('product', '')).strip(),
'logic': 'or', # JSONL 格式默认 or 逻辑
'rule': [item.get('rule', '')] if item.get('rule') else [],
}
def get_export_data(self) -> list:

View File

@@ -139,7 +139,7 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
POST /api/engine/fingerprints/{type}/import_file/
请求格式multipart/form-data
- file: JSON 文件
- file: JSON 文件(支持标准 JSON 和 JSONL 格式)
返回:同 batch_create
"""
@@ -148,9 +148,12 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
raise ValidationError('缺少文件')
try:
json_data = json.load(file)
content = file.read().decode('utf-8')
json_data = self._parse_json_content(content)
except json.JSONDecodeError as e:
raise ValidationError(f'无效的 JSON 格式: {e}')
except UnicodeDecodeError as e:
raise ValidationError(f'文件编码错误: {e}')
fingerprints = self.parse_import_data(json_data)
if not fingerprints:
@@ -159,6 +162,41 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
result = self.get_service().batch_create_fingerprints(fingerprints)
return success_response(data=result, status_code=status.HTTP_201_CREATED)
def _parse_json_content(self, content: str):
"""
解析 JSON 内容,支持标准 JSON 和 JSONL 格式
Args:
content: 文件内容字符串
Returns:
解析后的数据list 或 dict
"""
content = content.strip()
# 尝试标准 JSON 解析
try:
return json.loads(content)
except json.JSONDecodeError:
pass
# 尝试 JSONL 格式(每行一个 JSON 对象)
lines = content.split('\n')
result = []
for i, line in enumerate(lines):
line = line.strip()
if not line:
continue
try:
result.append(json.loads(line))
except json.JSONDecodeError as e:
raise json.JSONDecodeError(f'{i + 1} 行解析失败: {e.msg}', e.doc, e.pos)
if not result:
raise json.JSONDecodeError('文件为空或格式无效', content, 0)
return result
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request):
"""

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

@@ -21,9 +21,6 @@ urlpatterns = [
# 标记全部已读
path('mark-all-as-read/', NotificationMarkAllAsReadView.as_view(), name='mark-all-as-read'),
# 测试通知
path('test/', views.notifications_test, name='test'),
]
# WebSocket 实时通知路由在 routing.py 中定义ws://host/ws/notifications/

View File

@@ -23,45 +23,7 @@ from .services import NotificationService, NotificationSettingsService
logger = logging.getLogger(__name__)
def notifications_test(request):
"""
测试通知推送
"""
try:
from .services import create_notification
from django.http import JsonResponse
level_param = request.GET.get('level', NotificationLevel.LOW)
try:
level_choice = NotificationLevel(level_param)
except ValueError:
level_choice = NotificationLevel.LOW
title = request.GET.get('title') or "测试通知"
message = request.GET.get('message') or "这是一条测试通知消息"
# 创建测试通知
notification = create_notification(
title=title,
message=message,
level=level_choice
)
return JsonResponse({
'success': True,
'message': '测试通知已发送',
'notification_id': notification.id
})
except Exception as e:
logger.error(f"发送测试通知失败: {e}")
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
# build_api_response 已废弃,请使用 success_response/error_response
def _parse_bool(value: str | None) -> bool | None:

View File

@@ -65,7 +65,7 @@ class ScanHistorySerializer(serializers.ModelSerializer):
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""快速扫描序列化器"""
MAX_BATCH_SIZE = 1000
MAX_BATCH_SIZE = 5000
targets = serializers.ListField(
child=serializers.DictField(),

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

@@ -1,16 +1,21 @@
"""导出 Endpoint URL 到文件的 Task
使用 TargetExportService 统一处理导出逻辑和默认值回退
数据源: Endpoint.url
数据源优先级(回退链):
1. Endpoint.url - 最精细的 URL含路径、参数等
2. WebSite.url - 站点级别 URL
3. 默认生成 - 根据 Target 类型生成 http(s)://target_name
"""
import logging
from typing import Dict, Optional
from pathlib import Path
from typing import Dict
from prefect import task
from apps.asset.models import Endpoint
from apps.scan.services import TargetExportService, BlacklistService
from apps.asset.models import Endpoint, WebSite
from apps.scan.services.target_export_service import create_export_service
logger = logging.getLogger(__name__)
@@ -23,13 +28,10 @@ def export_endpoints_task(
) -> Dict[str, object]:
"""导出目标下的所有 Endpoint URL 到文本文件。
数据源: Endpoint.url
懒加载模式:
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
数据源优先级(回退链):
1. Endpoint 表 - 最精细的 URL含路径、参数等
2. WebSite 表 - 站点级别 URL
3. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID
@@ -41,25 +43,55 @@ def export_endpoints_task(
"success": bool,
"output_file": str,
"total_count": int,
"source": str, # 数据来源: "endpoint" | "website" | "default"
}
"""
# 构建数据源 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)
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
# 1. 优先从 Endpoint 表导出
endpoint_queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
result = export_service.export_urls(
target_id=target_id,
output_path=output_file,
queryset=queryset,
queryset=endpoint_queryset,
batch_size=batch_size
)
# 保持返回值格式不变(向后兼容)
if result['total_count'] > 0:
logger.info("从 Endpoint 表导出 %d 条 URL", result['total_count'])
return {
"success": True,
"output_file": result['output_file'],
"total_count": result['total_count'],
"source": "endpoint",
}
# 2. Endpoint 为空,回退到 WebSite 表
logger.info("Endpoint 表为空,回退到 WebSite 表")
website_queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
result = export_service.export_urls(
target_id=target_id,
output_path=output_file,
queryset=website_queryset,
batch_size=batch_size
)
if result['total_count'] > 0:
logger.info("从 WebSite 表导出 %d 条 URL", result['total_count'])
return {
"success": True,
"output_file": result['output_file'],
"total_count": result['total_count'],
"source": "website",
}
# 3. WebSite 也为空,生成默认 URLexport_urls 内部已处理)
logger.info("WebSite 表也为空,使用默认 URL 生成")
return {
"success": result['success'],
"success": True,
"output_file": result['output_file'],
"total_count": result['total_count'],
"source": "default",
}

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

@@ -238,15 +238,39 @@ export function ImportFingerprintDialog({
// Frontend basic validation for JSON files
try {
const text = await file.text()
const json = JSON.parse(text)
let json: any
// Try standard JSON first
try {
json = JSON.parse(text)
} catch {
// If standard JSON fails, try JSONL format (for goby)
if (fingerprintType === "goby") {
const lines = text.trim().split('\n').filter(line => line.trim())
if (lines.length === 0) {
toast.error(t("import.emptyData"))
return
}
// Parse each line as JSON
json = lines.map((line, index) => {
try {
return JSON.parse(line)
} catch {
throw new Error(`Line ${index + 1}: Invalid JSON`)
}
})
} else {
throw new Error("Invalid JSON")
}
}
const validation = config.validate(json)
if (!validation.valid) {
toast.error(validation.error)
return
}
} catch (e) {
toast.error(tToast("invalidJsonFile"))
} catch (e: any) {
toast.error(e.message || tToast("invalidJsonFile"))
return
}
}

View File

@@ -132,7 +132,7 @@ function TargetNameCell({
return (
<div className="group flex items-start gap-1 flex-1 min-w-0">
<button
onClick={() => navigate(`/target/${targetId}/website/`)}
onClick={() => navigate(`/target/${targetId}/overview/`)}
className="text-sm font-medium hover:text-primary hover:underline underline-offset-2 transition-colors cursor-pointer text-left break-all leading-relaxed whitespace-normal"
>
{name}
@@ -251,7 +251,7 @@ export const createTargetColumns = ({
cell: ({ row }) => (
<TargetRowActions
target={row.original}
onView={() => navigate(`/target/${row.original.id}/website/`)}
onView={() => navigate(`/target/${row.original.id}/overview/`)}
onDelete={() => handleDelete(row.original)}
t={t}
/>

View File

@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { IconSearch, IconLoader2 } from "@tabler/icons-react"
import { IconSearch, IconLoader2, IconPlus } from "@tabler/icons-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -74,6 +74,7 @@ export function TargetsDataTable({
// 自定义添加按钮(支持 onAddHover
const addButton = onAddNew ? (
<Button onClick={onAddNew} onMouseEnter={onAddHover} size="sm">
<IconPlus className="h-4 w-4" />
{addButtonText || tTarget("createTarget")}
</Button>
) : undefined

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-muted-foreground" />
<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 {