Compare commits

...

8 Commits

Author SHA1 Message Date
yyhuni
1269e5a314 refactor(scan): reorganize models and serializers into modular structure
- Split monolithic models.py into separate model files (scan_models.py, scan_log_model.py, scheduled_scan_model.py, subfinder_provider_settings_model.py)
- Split monolithic serializers.py into separate serializer files with dedicated modules for each domain
- Add SubfinderProviderSettings model to store API key configurations for subfinder data sources
- Create SubfinderProviderConfigService to generate provider configuration files dynamically
- Add subfinder_provider_settings views and serializers for API key management
- Update subdomain_discovery_flow to support provider configuration file generation and passing to subfinder
- Update command templates to use provider config file and remove recursive flag for better source coverage
- Add frontend settings page for managing API keys at /settings/api-keys
- Add frontend hooks and services for API key settings management
- Update sidebar navigation to include API keys settings link
- Add internationalization support for new API keys settings UI (English and Chinese)
- Improves code maintainability by organizing related models and serializers into logical modules
2026-01-05 10:00:19 +08:00
yyhuni
802e967906 docs: add online demo link to README
- Add new "🌐 在线 Demo" section with live demo URL
- Include disclaimer note that demo is UI-only without backend database
- Improve documentation to help users quickly access and test the application
2026-01-04 19:19:33 +08:00
github-actions[bot]
e446326416 chore: bump version to v1.3.14 2026-01-04 11:02:14 +00:00
yyhuni
e0abb3ce7b Merge branch 'dev' 2026-01-04 18:57:49 +08:00
yyhuni
d418baaf79 feat(mock,scan): add comprehensive mock data and improve system load management
- Add mock data files for directories, fingerprints, IP addresses, notification settings, nuclei templates, search, system logs, tools, and wordlists
- Update mock index to export new mock data modules
- Increase SCAN_LOAD_CHECK_INTERVAL from 30 to 180 seconds for better system stability
- Improve load check logging message to clarify OOM prevention strategy
- Enhance mock data infrastructure to support frontend development and testing
2026-01-04 18:52:08 +08:00
github-actions[bot]
f8da408580 chore: bump version to v1.3.13-dev 2026-01-04 10:24:10 +00:00
yyhuni
7b7bbed634 Update README.md 2026-01-03 22:15:35 +08:00
github-actions[bot]
08372588a4 chore: bump version to v1.2.15 2026-01-01 15:44:15 +00:00
40 changed files with 3084 additions and 669 deletions

View File

@@ -25,6 +25,13 @@
---
## 🌐 在线 Demo
👉 **[https://xingrin.vercel.app/](https://xingrin.vercel.app/)**
> ⚠️ 仅用于 UI 展示,未接入后端数据库
---
<p align="center">
<b>🎨 现代化 UI </b>

View File

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

View File

@@ -13,12 +13,14 @@ SCAN_TOOLS_BASE_PATH = getattr(settings, 'SCAN_TOOLS_BASE_PATH', '/usr/local/bin
SUBDOMAIN_DISCOVERY_COMMANDS = {
'subfinder': {
# 默认使用所有数据源(更全面,略慢),并始终开启递归
# -all 使用所有数据源
# -recursive 对支持递归的源启用递归枚举(默认开启
'base': "subfinder -d {domain} -all -recursive -o '{output_file}' -silent",
# 使用所有数据源(包括付费源,只要配置了 API key
# -all 使用所有数据源slow 但全面)
# -v 显示详细输出,包括使用的数据源(调试用
# 注意:不要加 -recursive它会排除不支持递归的源如 fofa
'base': "subfinder -d {domain} -all -o '{output_file}' -v",
'optional': {
'threads': '-t {threads}', # 控制并发 goroutine 数
'provider_config': "-pc '{provider_config}'", # Provider 配置文件路径
}
},

View File

@@ -78,7 +78,8 @@ def _run_scans_parallel(
enabled_tools: dict,
domain_name: str,
result_dir: Path,
scan_id: int
scan_id: int,
provider_config_path: str = None
) -> tuple[list, list, list]:
"""
并行运行所有启用的子域名扫描工具
@@ -88,6 +89,7 @@ def _run_scans_parallel(
domain_name: 目标域名
result_dir: 结果输出目录
scan_id: 扫描任务 ID用于记录日志
provider_config_path: Provider 配置文件路径(可选,用于 subfinder
Returns:
tuple: (result_files, failed_tools, successful_tool_names)
@@ -112,13 +114,19 @@ def _run_scans_parallel(
# 1.2 构建完整命令(变量替换)
try:
command_params = {
'domain': domain_name, # 对应 {domain}
'output_file': output_file # 对应 {output_file}
}
# 如果是 subfinder 且有 provider_config添加到参数
if tool_name == 'subfinder' and provider_config_path:
command_params['provider_config'] = provider_config_path
command = build_scan_command(
tool_name=tool_name,
scan_type='subdomain_discovery',
command_params={
'domain': domain_name, # 对应 {domain}
'output_file': output_file # 对应 {output_file}
},
command_params=command_params,
tool_config=tool_config
)
except Exception as e:
@@ -440,6 +448,19 @@ def subdomain_discovery_flow(
failed_tools = []
successful_tool_names = []
# ==================== 生成 Provider 配置文件 ====================
# 为 subfinder 生成第三方数据源配置
provider_config_path = None
try:
from apps.scan.services.subfinder_provider_config_service import SubfinderProviderConfigService
provider_config_service = SubfinderProviderConfigService()
provider_config_path = provider_config_service.generate(str(result_dir))
if provider_config_path:
logger.info(f"Provider 配置文件已生成: {provider_config_path}")
user_log(scan_id, "subdomain_discovery", "Provider config generated for subfinder")
except Exception as e:
logger.warning(f"生成 Provider 配置文件失败: {e}")
# ==================== Stage 1: 被动收集(并行)====================
if enabled_passive_tools:
logger.info("=" * 40)
@@ -451,7 +472,8 @@ def subdomain_discovery_flow(
enabled_tools=enabled_passive_tools,
domain_name=domain_name,
result_dir=result_dir,
scan_id=scan_id
scan_id=scan_id,
provider_config_path=provider_config_path
)
all_result_files.extend(result_files)
failed_tools.extend(stage1_failed)

View File

@@ -133,4 +133,18 @@ class Migration(migrations.Migration):
'indexes': [models.Index(fields=['scan', 'created_at'], name='scan_log_scan_id_e8c8f5_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',
},
),
]

View File

@@ -1,244 +0,0 @@
from django.db import models
from django.contrib.postgres.fields import ArrayField
from ..common.definitions import ScanStatus
class SoftDeleteManager(models.Manager):
"""软删除管理器:默认只返回未删除的记录"""
def get_queryset(self):
return super().get_queryset().filter(deleted_at__isnull=True)
class Scan(models.Model):
"""扫描任务模型"""
id = models.AutoField(primary_key=True)
target = models.ForeignKey('targets.Target', on_delete=models.CASCADE, related_name='scans', help_text='扫描目标')
# 多引擎支持字段
engine_ids = ArrayField(
models.IntegerField(),
default=list,
help_text='引擎 ID 列表'
)
engine_names = models.JSONField(
default=list,
help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
)
yaml_configuration = models.TextField(
default='',
help_text='YAML 格式的扫描配置'
)
created_at = models.DateTimeField(auto_now_add=True, help_text='任务创建时间')
stopped_at = models.DateTimeField(null=True, blank=True, help_text='扫描结束时间')
status = models.CharField(
max_length=20,
choices=ScanStatus.choices,
default=ScanStatus.INITIATED,
db_index=True,
help_text='任务状态'
)
results_dir = models.CharField(max_length=100, blank=True, default='', help_text='结果存储目录')
container_ids = ArrayField(
models.CharField(max_length=100),
blank=True,
default=list,
help_text='容器 ID 列表Docker Container ID'
)
worker = models.ForeignKey(
'engine.WorkerNode',
on_delete=models.SET_NULL,
related_name='scans',
null=True,
blank=True,
help_text='执行扫描的 Worker 节点'
)
error_message = models.CharField(max_length=2000, blank=True, default='', help_text='错误信息')
# ==================== 软删除字段 ====================
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text='删除时间NULL表示未删除')
# ==================== 管理器 ====================
objects = SoftDeleteManager() # 默认管理器:只返回未删除的记录
all_objects = models.Manager() # 全量管理器:包括已删除的记录(用于硬删除)
# ==================== 进度跟踪字段 ====================
progress = models.IntegerField(default=0, help_text='扫描进度 0-100')
current_stage = models.CharField(max_length=50, blank=True, default='', help_text='当前扫描阶段')
stage_progress = models.JSONField(default=dict, help_text='各阶段进度详情')
# ==================== 缓存统计字段 ====================
cached_subdomains_count = models.IntegerField(default=0, help_text='缓存的子域名数量')
cached_websites_count = models.IntegerField(default=0, help_text='缓存的网站数量')
cached_endpoints_count = models.IntegerField(default=0, help_text='缓存的端点数量')
cached_ips_count = models.IntegerField(default=0, help_text='缓存的IP地址数量')
cached_directories_count = models.IntegerField(default=0, help_text='缓存的目录数量')
cached_vulns_total = models.IntegerField(default=0, help_text='缓存的漏洞总数')
cached_vulns_critical = models.IntegerField(default=0, help_text='缓存的严重漏洞数量')
cached_vulns_high = models.IntegerField(default=0, help_text='缓存的高危漏洞数量')
cached_vulns_medium = models.IntegerField(default=0, help_text='缓存的中危漏洞数量')
cached_vulns_low = models.IntegerField(default=0, help_text='缓存的低危漏洞数量')
stats_updated_at = models.DateTimeField(null=True, blank=True, help_text='统计数据最后更新时间')
class Meta:
db_table = 'scan'
verbose_name = '扫描任务'
verbose_name_plural = '扫描任务'
ordering = ['-created_at']
indexes = [
models.Index(fields=['-created_at']), # 优化按创建时间降序排序list 查询的默认排序)
models.Index(fields=['target']), # 优化按目标查询扫描任务
models.Index(fields=['deleted_at', '-created_at']), # 软删除 + 时间索引
]
def __str__(self):
return f"Scan #{self.id} - {self.target.name}"
class ScanLog(models.Model):
"""扫描日志模型
存储扫描过程中的关键处理日志,用于前端实时查看扫描进度。
日志类型:
- 阶段开始/完成/失败
- 处理进度(如 "Progress: 50/120"
- 发现结果统计(如 "Found 120 subdomains"
- 错误信息
日志格式:[stage_name] message
"""
class Level(models.TextChoices):
INFO = 'info', 'Info'
WARNING = 'warning', 'Warning'
ERROR = 'error', 'Error'
id = models.BigAutoField(primary_key=True)
scan = models.ForeignKey(
'Scan',
on_delete=models.CASCADE,
related_name='logs',
db_index=True,
help_text='关联的扫描任务'
)
level = models.CharField(
max_length=10,
choices=Level.choices,
default=Level.INFO,
help_text='日志级别'
)
content = models.TextField(help_text='日志内容')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')
class Meta:
db_table = 'scan_log'
verbose_name = '扫描日志'
verbose_name_plural = '扫描日志'
ordering = ['created_at']
indexes = [
models.Index(fields=['scan', 'created_at']),
]
def __str__(self):
return f"[{self.level}] {self.content[:50]}"
class ScheduledScan(models.Model):
"""
定时扫描任务模型
调度机制:
- APScheduler 每分钟检查 next_run_time
- 到期任务通过 task_distributor 分发到 Worker 执行
- 支持 cron 表达式进行灵活调度
扫描模式(二选一):
- 组织扫描:设置 organization执行时动态获取组织下所有目标
- 目标扫描:设置 target扫描单个目标
- organization 优先级高于 target
"""
id = models.AutoField(primary_key=True)
# 基本信息
name = models.CharField(max_length=200, help_text='任务名称')
# 多引擎支持字段
engine_ids = ArrayField(
models.IntegerField(),
default=list,
help_text='引擎 ID 列表'
)
engine_names = models.JSONField(
default=list,
help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
)
yaml_configuration = models.TextField(
default='',
help_text='YAML 格式的扫描配置'
)
# 关联的组织(组织扫描模式:执行时动态获取组织下所有目标)
organization = models.ForeignKey(
'targets.Organization',
on_delete=models.CASCADE,
related_name='scheduled_scans',
null=True,
blank=True,
help_text='扫描组织(设置后执行时动态获取组织下所有目标)'
)
# 关联的目标(目标扫描模式:扫描单个目标)
target = models.ForeignKey(
'targets.Target',
on_delete=models.CASCADE,
related_name='scheduled_scans',
null=True,
blank=True,
help_text='扫描单个目标(与 organization 二选一)'
)
# 调度配置 - 直接使用 Cron 表达式
cron_expression = models.CharField(
max_length=100,
default='0 2 * * *',
help_text='Cron 表达式,格式:分 时 日 月 周'
)
# 状态
is_enabled = models.BooleanField(default=True, db_index=True, help_text='是否启用')
# 执行统计
run_count = models.IntegerField(default=0, help_text='已执行次数')
last_run_time = models.DateTimeField(null=True, blank=True, help_text='上次执行时间')
next_run_time = models.DateTimeField(null=True, blank=True, help_text='下次执行时间')
# 时间戳
created_at = models.DateTimeField(auto_now_add=True, help_text='创建时间')
updated_at = models.DateTimeField(auto_now=True, help_text='更新时间')
class Meta:
db_table = 'scheduled_scan'
verbose_name = '定时扫描任务'
verbose_name_plural = '定时扫描任务'
ordering = ['-created_at']
indexes = [
models.Index(fields=['-created_at']),
models.Index(fields=['is_enabled', '-created_at']),
models.Index(fields=['name']), # 优化 name 搜索
]
def __str__(self):
return f"ScheduledScan #{self.id} - {self.name}"

View File

@@ -0,0 +1,18 @@
"""Scan Models - 统一导出"""
from .scan_models import Scan, SoftDeleteManager
from .scan_log_model import ScanLog
from .scheduled_scan_model import ScheduledScan
from .subfinder_provider_settings_model import SubfinderProviderSettings
# 兼容旧名称(已废弃,请使用 SubfinderProviderSettings
ProviderSettings = SubfinderProviderSettings
__all__ = [
'Scan',
'ScanLog',
'ScheduledScan',
'SoftDeleteManager',
'SubfinderProviderSettings',
'ProviderSettings', # 兼容旧名称
]

View File

@@ -0,0 +1,41 @@
"""扫描日志模型"""
from django.db import models
class ScanLog(models.Model):
"""扫描日志模型"""
class Level(models.TextChoices):
INFO = 'info', 'Info'
WARNING = 'warning', 'Warning'
ERROR = 'error', 'Error'
id = models.BigAutoField(primary_key=True)
scan = models.ForeignKey(
'Scan',
on_delete=models.CASCADE,
related_name='logs',
db_index=True,
help_text='关联的扫描任务'
)
level = models.CharField(
max_length=10,
choices=Level.choices,
default=Level.INFO,
help_text='日志级别'
)
content = models.TextField(help_text='日志内容')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')
class Meta:
db_table = 'scan_log'
verbose_name = '扫描日志'
verbose_name_plural = '扫描日志'
ordering = ['created_at']
indexes = [
models.Index(fields=['scan', 'created_at']),
]
def __str__(self):
return f"[{self.level}] {self.content[:50]}"

View File

@@ -0,0 +1,106 @@
"""扫描相关模型"""
from django.db import models
from django.contrib.postgres.fields import ArrayField
from apps.common.definitions import ScanStatus
class SoftDeleteManager(models.Manager):
"""软删除管理器:默认只返回未删除的记录"""
def get_queryset(self):
return super().get_queryset().filter(deleted_at__isnull=True)
class Scan(models.Model):
"""扫描任务模型"""
id = models.AutoField(primary_key=True)
target = models.ForeignKey('targets.Target', on_delete=models.CASCADE, related_name='scans', help_text='扫描目标')
# 多引擎支持字段
engine_ids = ArrayField(
models.IntegerField(),
default=list,
help_text='引擎 ID 列表'
)
engine_names = models.JSONField(
default=list,
help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
)
yaml_configuration = models.TextField(
default='',
help_text='YAML 格式的扫描配置'
)
created_at = models.DateTimeField(auto_now_add=True, help_text='任务创建时间')
stopped_at = models.DateTimeField(null=True, blank=True, help_text='扫描结束时间')
status = models.CharField(
max_length=20,
choices=ScanStatus.choices,
default=ScanStatus.INITIATED,
db_index=True,
help_text='任务状态'
)
results_dir = models.CharField(max_length=100, blank=True, default='', help_text='结果存储目录')
container_ids = ArrayField(
models.CharField(max_length=100),
blank=True,
default=list,
help_text='容器 ID 列表Docker Container ID'
)
worker = models.ForeignKey(
'engine.WorkerNode',
on_delete=models.SET_NULL,
related_name='scans',
null=True,
blank=True,
help_text='执行扫描的 Worker 节点'
)
error_message = models.CharField(max_length=2000, blank=True, default='', help_text='错误信息')
# ==================== 软删除字段 ====================
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text='删除时间NULL表示未删除')
# ==================== 管理器 ====================
objects = SoftDeleteManager() # 默认管理器:只返回未删除的记录
all_objects = models.Manager() # 全量管理器:包括已删除的记录(用于硬删除)
# ==================== 进度跟踪字段 ====================
progress = models.IntegerField(default=0, help_text='扫描进度 0-100')
current_stage = models.CharField(max_length=50, blank=True, default='', help_text='当前扫描阶段')
stage_progress = models.JSONField(default=dict, help_text='各阶段进度详情')
# ==================== 缓存统计字段 ====================
cached_subdomains_count = models.IntegerField(default=0, help_text='缓存的子域名数量')
cached_websites_count = models.IntegerField(default=0, help_text='缓存的网站数量')
cached_endpoints_count = models.IntegerField(default=0, help_text='缓存的端点数量')
cached_ips_count = models.IntegerField(default=0, help_text='缓存的IP地址数量')
cached_directories_count = models.IntegerField(default=0, help_text='缓存的目录数量')
cached_vulns_total = models.IntegerField(default=0, help_text='缓存的漏洞总数')
cached_vulns_critical = models.IntegerField(default=0, help_text='缓存的严重漏洞数量')
cached_vulns_high = models.IntegerField(default=0, help_text='缓存的高危漏洞数量')
cached_vulns_medium = models.IntegerField(default=0, help_text='缓存的中危漏洞数量')
cached_vulns_low = models.IntegerField(default=0, help_text='缓存的低危漏洞数量')
stats_updated_at = models.DateTimeField(null=True, blank=True, help_text='统计数据最后更新时间')
class Meta:
db_table = 'scan'
verbose_name = '扫描任务'
verbose_name_plural = '扫描任务'
ordering = ['-created_at']
indexes = [
models.Index(fields=['-created_at']),
models.Index(fields=['target']),
models.Index(fields=['deleted_at', '-created_at']),
]
def __str__(self):
return f"Scan #{self.id} - {self.target.name}"

View File

@@ -0,0 +1,73 @@
"""定时扫描任务模型"""
from django.db import models
from django.contrib.postgres.fields import ArrayField
class ScheduledScan(models.Model):
"""定时扫描任务模型"""
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=200, help_text='任务名称')
engine_ids = ArrayField(
models.IntegerField(),
default=list,
help_text='引擎 ID 列表'
)
engine_names = models.JSONField(
default=list,
help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
)
yaml_configuration = models.TextField(
default='',
help_text='YAML 格式的扫描配置'
)
organization = models.ForeignKey(
'targets.Organization',
on_delete=models.CASCADE,
related_name='scheduled_scans',
null=True,
blank=True,
help_text='扫描组织(设置后执行时动态获取组织下所有目标)'
)
target = models.ForeignKey(
'targets.Target',
on_delete=models.CASCADE,
related_name='scheduled_scans',
null=True,
blank=True,
help_text='扫描单个目标(与 organization 二选一)'
)
cron_expression = models.CharField(
max_length=100,
default='0 2 * * *',
help_text='Cron 表达式,格式:分 时 日 月 周'
)
is_enabled = models.BooleanField(default=True, db_index=True, help_text='是否启用')
run_count = models.IntegerField(default=0, help_text='已执行次数')
last_run_time = models.DateTimeField(null=True, blank=True, help_text='上次执行时间')
next_run_time = models.DateTimeField(null=True, blank=True, help_text='下次执行时间')
created_at = models.DateTimeField(auto_now_add=True, help_text='创建时间')
updated_at = models.DateTimeField(auto_now=True, help_text='更新时间')
class Meta:
db_table = 'scheduled_scan'
verbose_name = '定时扫描任务'
verbose_name_plural = '定时扫描任务'
ordering = ['-created_at']
indexes = [
models.Index(fields=['-created_at']),
models.Index(fields=['is_enabled', '-created_at']),
models.Index(fields=['name']),
]
def __str__(self):
return f"ScheduledScan #{self.id} - {self.name}"

View File

@@ -0,0 +1,64 @@
"""Subfinder Provider 配置模型(单例模式)
用于存储 subfinder 第三方数据源的 API Key 配置
"""
from django.db import models
class SubfinderProviderSettings(models.Model):
"""
Subfinder Provider 配置(单例模式)
存储第三方数据源的 API Key 配置,用于 subfinder 子域名发现
支持的 Provider:
- fofa: email + api_key (composite)
- censys: api_id + api_secret (composite)
- hunter, shodan, zoomeye, securitytrails, threatbook, quake: api_key (single)
"""
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)
class Meta:
db_table = 'subfinder_provider_settings'
verbose_name = 'Subfinder Provider 配置'
verbose_name_plural = 'Subfinder Provider 配置'
DEFAULT_PROVIDERS = {
'fofa': {'enabled': False, 'email': '', 'api_key': ''},
'hunter': {'enabled': False, 'api_key': ''},
'shodan': {'enabled': False, 'api_key': ''},
'censys': {'enabled': False, 'api_id': '', 'api_secret': ''},
'zoomeye': {'enabled': False, 'api_key': ''},
'securitytrails': {'enabled': False, 'api_key': ''},
'threatbook': {'enabled': False, 'api_key': ''},
'quake': {'enabled': False, 'api_key': ''},
}
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_instance(cls) -> 'SubfinderProviderSettings':
"""获取或创建单例实例"""
obj, _ = cls.objects.get_or_create(
pk=1,
defaults={'providers': cls.DEFAULT_PROVIDERS.copy()}
)
return obj
def get_provider_config(self, provider: str) -> dict:
"""获取指定 Provider 的配置"""
return self.providers.get(provider, self.DEFAULT_PROVIDERS.get(provider, {}))
def is_provider_enabled(self, provider: str) -> bool:
"""检查指定 Provider 是否启用"""
config = self.get_provider_config(provider)
return config.get('enabled', False)

View File

@@ -1,411 +0,0 @@
from rest_framework import serializers
from django.db.models import Count
import yaml
from .models import Scan, ScheduledScan, ScanLog
# ==================== 扫描日志序列化器 ====================
class ScanLogSerializer(serializers.ModelSerializer):
"""扫描日志序列化器"""
class Meta:
model = ScanLog
fields = ['id', 'level', 'content', 'created_at']
# ==================== 通用验证 Mixin ====================
class DuplicateKeyLoader(yaml.SafeLoader):
"""自定义 YAML Loader检测重复 key"""
pass
def _check_duplicate_keys(loader, node, deep=False):
"""检测 YAML mapping 中的重复 key"""
mapping = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in mapping:
raise yaml.constructor.ConstructorError(
"while constructing a mapping", node.start_mark,
f"发现重复的配置项 '{key}',后面的配置会覆盖前面的配置,请删除重复项", key_node.start_mark
)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
DuplicateKeyLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
_check_duplicate_keys
)
class ScanConfigValidationMixin:
"""扫描配置验证 Mixin提供通用的验证方法"""
def validate_configuration(self, value):
"""验证 YAML 配置格式,包括检测重复 key"""
import yaml
if not value or not value.strip():
raise serializers.ValidationError("configuration 不能为空")
try:
# 使用自定义 Loader 检测重复 key
yaml.load(value, Loader=DuplicateKeyLoader)
except yaml.YAMLError as e:
raise serializers.ValidationError(f"无效的 YAML 格式: {str(e)}")
return value
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if not value:
raise serializers.ValidationError("engine_ids 不能为空,请至少选择一个扫描引擎")
return value
def validate_engine_names(self, value):
"""验证引擎名称列表"""
if not value:
raise serializers.ValidationError("engine_names 不能为空")
return value
# ==================== 扫描任务序列化器 ====================
class ScanSerializer(serializers.ModelSerializer):
"""扫描任务序列化器"""
target_name = serializers.SerializerMethodField()
class Meta:
model = Scan
fields = [
'id', 'target', 'target_name', 'engine_ids', 'engine_names',
'created_at', 'stopped_at', 'status', 'results_dir',
'container_ids', 'error_message'
]
read_only_fields = [
'id', 'created_at', 'stopped_at', 'results_dir',
'container_ids', 'error_message', 'status'
]
def get_target_name(self, obj):
"""获取目标名称"""
return obj.target.name if obj.target else None
class ScanHistorySerializer(serializers.ModelSerializer):
"""扫描历史列表专用序列化器
为前端扫描历史页面提供优化的数据格式,包括:
- 扫描汇总统计(子域名、端点、漏洞数量)
- 进度百分比和当前阶段
- 执行节点信息
"""
# 字段映射
target_name = serializers.CharField(source='target.name', read_only=True)
worker_name = serializers.CharField(source='worker.name', read_only=True, allow_null=True)
# 计算字段
summary = serializers.SerializerMethodField()
# 进度跟踪字段(直接从模型读取)
progress = serializers.IntegerField(read_only=True)
current_stage = serializers.CharField(read_only=True)
stage_progress = serializers.JSONField(read_only=True)
class Meta:
model = Scan
fields = [
'id', 'target', 'target_name', 'engine_ids', 'engine_names',
'worker_name', 'created_at', 'status', 'error_message', 'summary',
'progress', 'current_stage', 'stage_progress'
]
def get_summary(self, obj):
"""获取扫描汇总数据。
设计原则:
- 子域名/网站/端点/IP/目录使用缓存字段(避免实时 COUNT
- 漏洞统计使用 Scan 上的缓存字段,在扫描结束时统一聚合
"""
# 1. 使用缓存字段构建基础统计子域名、网站、端点、IP、目录
summary = {
'subdomains': obj.cached_subdomains_count or 0,
'websites': obj.cached_websites_count or 0,
'endpoints': obj.cached_endpoints_count or 0,
'ips': obj.cached_ips_count or 0,
'directories': obj.cached_directories_count or 0,
}
# 2. 使用 Scan 模型上的缓存漏洞统计(按严重性聚合)
summary['vulnerabilities'] = {
'total': obj.cached_vulns_total or 0,
'critical': obj.cached_vulns_critical or 0,
'high': obj.cached_vulns_high or 0,
'medium': obj.cached_vulns_medium or 0,
'low': obj.cached_vulns_low or 0,
}
return summary
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""
快速扫描序列化器
功能:
- 接收目标列表和 YAML 配置
- 自动创建/获取目标
- 立即发起扫描
"""
# 批量创建的最大数量限制
MAX_BATCH_SIZE = 1000
# 目标列表
targets = serializers.ListField(
child=serializers.DictField(),
help_text='目标列表,每个目标包含 name 字段'
)
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填,用于记录和显示)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
help_text='使用的扫描引擎 ID 列表(必填)'
)
# 引擎名称列表(必填,用于记录和显示)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
def validate_targets(self, value):
"""验证目标列表"""
if not value:
raise serializers.ValidationError("目标列表不能为空")
# 检查数量限制,防止服务器过载
if len(value) > self.MAX_BATCH_SIZE:
raise serializers.ValidationError(
f"快速扫描最多支持 {self.MAX_BATCH_SIZE} 个目标,当前提交了 {len(value)}"
)
# 验证每个目标的必填字段
for idx, target in enumerate(value):
if 'name' not in target:
raise serializers.ValidationError(f"{idx + 1} 个目标缺少 name 字段")
if not target['name']:
raise serializers.ValidationError(f"{idx + 1} 个目标的 name 不能为空")
return value
# ==================== 定时扫描序列化器 ====================
class ScheduledScanSerializer(serializers.ModelSerializer):
"""定时扫描任务序列化器(用于列表和详情)"""
# 关联字段
organization_id = serializers.IntegerField(source='organization.id', read_only=True, allow_null=True)
organization_name = serializers.CharField(source='organization.name', read_only=True, allow_null=True)
target_id = serializers.IntegerField(source='target.id', read_only=True, allow_null=True)
target_name = serializers.CharField(source='target.name', read_only=True, allow_null=True)
scan_mode = serializers.SerializerMethodField()
class Meta:
model = ScheduledScan
fields = [
'id', 'name',
'engine_ids', 'engine_names',
'organization_id', 'organization_name',
'target_id', 'target_name',
'scan_mode',
'cron_expression',
'is_enabled',
'run_count', 'last_run_time', 'next_run_time',
'created_at', 'updated_at'
]
read_only_fields = [
'id', 'run_count',
'last_run_time', 'next_run_time',
'created_at', 'updated_at'
]
def get_scan_mode(self, obj):
"""获取扫描模式organization 或 target"""
return 'organization' if obj.organization_id else 'target'
class CreateScheduledScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""创建定时扫描任务序列化器
扫描模式(二选一):
- 组织扫描:提供 organization_id执行时动态获取组织下所有目标
- 目标扫描:提供 target_id扫描单个目标
"""
name = serializers.CharField(max_length=200, help_text='任务名称')
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填,用于记录和显示)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
help_text='扫描引擎 ID 列表(必填)'
)
# 引擎名称列表(必填,用于记录和显示)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
# 组织扫描模式
organization_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='组织 ID组织扫描模式执行时动态获取组织下所有目标'
)
# 目标扫描模式
target_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='目标 ID目标扫描模式扫描单个目标'
)
cron_expression = serializers.CharField(
max_length=100,
default='0 2 * * *',
help_text='Cron 表达式,格式:分 时 日 月 周'
)
is_enabled = serializers.BooleanField(default=True, help_text='是否立即启用')
def validate(self, data):
"""验证 organization_id 和 target_id 互斥"""
organization_id = data.get('organization_id')
target_id = data.get('target_id')
if not organization_id and not target_id:
raise serializers.ValidationError('必须提供 organization_id 或 target_id 其中之一')
if organization_id and target_id:
raise serializers.ValidationError('organization_id 和 target_id 只能提供其中之一')
return data
class InitiateScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""发起扫描任务序列化器
扫描模式(二选一):
- 组织扫描:提供 organization_id扫描组织下所有目标
- 目标扫描:提供 target_id扫描单个目标
"""
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
help_text='扫描引擎 ID 列表(必填)'
)
# 引擎名称列表(必填)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
# 组织扫描模式
organization_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='组织 ID组织扫描模式'
)
# 目标扫描模式
target_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='目标 ID目标扫描模式'
)
def validate(self, data):
"""验证 organization_id 和 target_id 互斥"""
organization_id = data.get('organization_id')
target_id = data.get('target_id')
if not organization_id and not target_id:
raise serializers.ValidationError('必须提供 organization_id 或 target_id 其中之一')
if organization_id and target_id:
raise serializers.ValidationError('organization_id 和 target_id 只能提供其中之一')
return data
class UpdateScheduledScanSerializer(serializers.Serializer):
"""更新定时扫描任务序列化器"""
name = serializers.CharField(max_length=200, required=False, help_text='任务名称')
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
required=False,
help_text='扫描引擎 ID 列表'
)
# 组织扫描模式
organization_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='组织 ID设置后清空 target_id'
)
# 目标扫描模式
target_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='目标 ID设置后清空 organization_id'
)
cron_expression = serializers.CharField(max_length=100, required=False, help_text='Cron 表达式')
is_enabled = serializers.BooleanField(required=False, help_text='是否启用')
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if value is not None and not value:
raise serializers.ValidationError("engine_ids 不能为空")
return value
class ToggleScheduledScanSerializer(serializers.Serializer):
"""切换定时扫描启用状态序列化器"""
is_enabled = serializers.BooleanField(help_text='是否启用')

View File

@@ -0,0 +1,40 @@
"""Scan Serializers - 统一导出"""
from .mixins import ScanConfigValidationMixin
from .scan_serializers import (
ScanSerializer,
ScanHistorySerializer,
QuickScanSerializer,
InitiateScanSerializer,
)
from .scan_log_serializers import ScanLogSerializer
from .scheduled_scan_serializers import (
ScheduledScanSerializer,
CreateScheduledScanSerializer,
UpdateScheduledScanSerializer,
ToggleScheduledScanSerializer,
)
from .subfinder_provider_settings_serializers import SubfinderProviderSettingsSerializer
# 兼容旧名称
ProviderSettingsSerializer = SubfinderProviderSettingsSerializer
__all__ = [
# Mixins
'ScanConfigValidationMixin',
# Scan
'ScanSerializer',
'ScanHistorySerializer',
'QuickScanSerializer',
'InitiateScanSerializer',
# ScanLog
'ScanLogSerializer',
# Scheduled Scan
'ScheduledScanSerializer',
'CreateScheduledScanSerializer',
'UpdateScheduledScanSerializer',
'ToggleScheduledScanSerializer',
# Subfinder Provider Settings
'SubfinderProviderSettingsSerializer',
'ProviderSettingsSerializer', # 兼容旧名称
]

View File

@@ -0,0 +1,57 @@
"""序列化器通用 Mixin 和工具类"""
from rest_framework import serializers
import yaml
class DuplicateKeyLoader(yaml.SafeLoader):
"""自定义 YAML Loader检测重复 key"""
pass
def _check_duplicate_keys(loader, node, deep=False):
"""检测 YAML mapping 中的重复 key"""
mapping = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in mapping:
raise yaml.constructor.ConstructorError(
"while constructing a mapping", node.start_mark,
f"发现重复的配置项 '{key}',后面的配置会覆盖前面的配置,请删除重复项", key_node.start_mark
)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
DuplicateKeyLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
_check_duplicate_keys
)
class ScanConfigValidationMixin:
"""扫描配置验证 Mixin"""
def validate_configuration(self, value):
"""验证 YAML 配置格式"""
if not value or not value.strip():
raise serializers.ValidationError("configuration 不能为空")
try:
yaml.load(value, Loader=DuplicateKeyLoader)
except yaml.YAMLError as e:
raise serializers.ValidationError(f"无效的 YAML 格式: {str(e)}")
return value
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if not value:
raise serializers.ValidationError("engine_ids 不能为空,请至少选择一个扫描引擎")
return value
def validate_engine_names(self, value):
"""验证引擎名称列表"""
if not value:
raise serializers.ValidationError("engine_names 不能为空")
return value

View File

@@ -0,0 +1,13 @@
"""扫描日志序列化器"""
from rest_framework import serializers
from ..models import ScanLog
class ScanLogSerializer(serializers.ModelSerializer):
"""扫描日志序列化器"""
class Meta:
model = ScanLog
fields = ['id', 'level', 'content', 'created_at']

View File

@@ -0,0 +1,111 @@
"""扫描任务序列化器"""
from rest_framework import serializers
from ..models import Scan
from .mixins import ScanConfigValidationMixin
class ScanSerializer(serializers.ModelSerializer):
"""扫描任务序列化器"""
target_name = serializers.SerializerMethodField()
class Meta:
model = Scan
fields = [
'id', 'target', 'target_name', 'engine_ids', 'engine_names',
'created_at', 'stopped_at', 'status', 'results_dir',
'container_ids', 'error_message'
]
read_only_fields = [
'id', 'created_at', 'stopped_at', 'results_dir',
'container_ids', 'error_message', 'status'
]
def get_target_name(self, obj):
return obj.target.name if obj.target else None
class ScanHistorySerializer(serializers.ModelSerializer):
"""扫描历史列表序列化器"""
target_name = serializers.CharField(source='target.name', read_only=True)
worker_name = serializers.CharField(source='worker.name', read_only=True, allow_null=True)
summary = serializers.SerializerMethodField()
progress = serializers.IntegerField(read_only=True)
current_stage = serializers.CharField(read_only=True)
stage_progress = serializers.JSONField(read_only=True)
class Meta:
model = Scan
fields = [
'id', 'target', 'target_name', 'engine_ids', 'engine_names',
'worker_name', 'created_at', 'status', 'error_message', 'summary',
'progress', 'current_stage', 'stage_progress'
]
def get_summary(self, obj):
summary = {
'subdomains': obj.cached_subdomains_count or 0,
'websites': obj.cached_websites_count or 0,
'endpoints': obj.cached_endpoints_count or 0,
'ips': obj.cached_ips_count or 0,
'directories': obj.cached_directories_count or 0,
}
summary['vulnerabilities'] = {
'total': obj.cached_vulns_total or 0,
'critical': obj.cached_vulns_critical or 0,
'high': obj.cached_vulns_high or 0,
'medium': obj.cached_vulns_medium or 0,
'low': obj.cached_vulns_low or 0,
}
return summary
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""快速扫描序列化器"""
MAX_BATCH_SIZE = 1000
targets = serializers.ListField(
child=serializers.DictField(),
help_text='目标列表,每个目标包含 name 字段'
)
configuration = serializers.CharField(required=True, help_text='YAML 格式的扫描配置')
engine_ids = serializers.ListField(child=serializers.IntegerField(), required=True)
engine_names = serializers.ListField(child=serializers.CharField(), required=True)
def validate_targets(self, value):
if not value:
raise serializers.ValidationError("目标列表不能为空")
if len(value) > self.MAX_BATCH_SIZE:
raise serializers.ValidationError(
f"快速扫描最多支持 {self.MAX_BATCH_SIZE} 个目标,当前提交了 {len(value)}"
)
for idx, target in enumerate(value):
if 'name' not in target:
raise serializers.ValidationError(f"{idx + 1} 个目标缺少 name 字段")
if not target['name']:
raise serializers.ValidationError(f"{idx + 1} 个目标的 name 不能为空")
return value
class InitiateScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""发起扫描任务序列化器"""
configuration = serializers.CharField(required=True, help_text='YAML 格式的扫描配置')
engine_ids = serializers.ListField(child=serializers.IntegerField(), required=True)
engine_names = serializers.ListField(child=serializers.CharField(), required=True)
organization_id = serializers.IntegerField(required=False, allow_null=True)
target_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, data):
organization_id = data.get('organization_id')
target_id = data.get('target_id')
if not organization_id and not target_id:
raise serializers.ValidationError('必须提供 organization_id 或 target_id 其中之一')
if organization_id and target_id:
raise serializers.ValidationError('organization_id 和 target_id 只能提供其中之一')
return data

View File

@@ -0,0 +1,84 @@
"""定时扫描序列化器"""
from rest_framework import serializers
from ..models import ScheduledScan
from .mixins import ScanConfigValidationMixin
class ScheduledScanSerializer(serializers.ModelSerializer):
"""定时扫描任务序列化器(用于列表和详情)"""
organization_id = serializers.IntegerField(source='organization.id', read_only=True, allow_null=True)
organization_name = serializers.CharField(source='organization.name', read_only=True, allow_null=True)
target_id = serializers.IntegerField(source='target.id', read_only=True, allow_null=True)
target_name = serializers.CharField(source='target.name', read_only=True, allow_null=True)
scan_mode = serializers.SerializerMethodField()
class Meta:
model = ScheduledScan
fields = [
'id', 'name',
'engine_ids', 'engine_names',
'organization_id', 'organization_name',
'target_id', 'target_name',
'scan_mode',
'cron_expression',
'is_enabled',
'run_count', 'last_run_time', 'next_run_time',
'created_at', 'updated_at'
]
read_only_fields = [
'id', 'run_count',
'last_run_time', 'next_run_time',
'created_at', 'updated_at'
]
def get_scan_mode(self, obj):
return 'organization' if obj.organization_id else 'target'
class CreateScheduledScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""创建定时扫描任务序列化器"""
name = serializers.CharField(max_length=200, help_text='任务名称')
configuration = serializers.CharField(required=True, help_text='YAML 格式的扫描配置')
engine_ids = serializers.ListField(child=serializers.IntegerField(), required=True)
engine_names = serializers.ListField(child=serializers.CharField(), required=True)
organization_id = serializers.IntegerField(required=False, allow_null=True)
target_id = serializers.IntegerField(required=False, allow_null=True)
cron_expression = serializers.CharField(max_length=100, default='0 2 * * *')
is_enabled = serializers.BooleanField(default=True)
def validate(self, data):
organization_id = data.get('organization_id')
target_id = data.get('target_id')
if not organization_id and not target_id:
raise serializers.ValidationError('必须提供 organization_id 或 target_id 其中之一')
if organization_id and target_id:
raise serializers.ValidationError('organization_id 和 target_id 只能提供其中之一')
return data
class UpdateScheduledScanSerializer(serializers.Serializer):
"""更新定时扫描任务序列化器"""
name = serializers.CharField(max_length=200, required=False)
engine_ids = serializers.ListField(child=serializers.IntegerField(), required=False)
organization_id = serializers.IntegerField(required=False, allow_null=True)
target_id = serializers.IntegerField(required=False, allow_null=True)
cron_expression = serializers.CharField(max_length=100, required=False)
is_enabled = serializers.BooleanField(required=False)
def validate_engine_ids(self, value):
if value is not None and not value:
raise serializers.ValidationError("engine_ids 不能为空")
return value
class ToggleScheduledScanSerializer(serializers.Serializer):
"""切换定时扫描启用状态序列化器"""
is_enabled = serializers.BooleanField(help_text='是否启用')

View File

@@ -0,0 +1,55 @@
"""Subfinder Provider 配置序列化器"""
from rest_framework import serializers
class SubfinderProviderSettingsSerializer(serializers.Serializer):
"""Subfinder Provider 配置序列化器
支持的 Provider:
- fofa: email + api_key (composite)
- censys: api_id + api_secret (composite)
- hunter, shodan, zoomeye, securitytrails, threatbook, quake: api_key (single)
注意djangorestframework-camel-case 会自动处理 camelCase <-> snake_case 转换
所以这里统一使用 snake_case
"""
VALID_PROVIDERS = {
'fofa', 'hunter', 'shodan', 'censys',
'zoomeye', 'securitytrails', 'threatbook', 'quake'
}
def to_internal_value(self, data):
"""验证并转换输入数据"""
if not isinstance(data, dict):
raise serializers.ValidationError('Expected a dictionary')
result = {}
for provider, config in data.items():
if provider not in self.VALID_PROVIDERS:
continue
if not isinstance(config, dict):
continue
db_config = {'enabled': bool(config.get('enabled', False))}
if provider == 'fofa':
db_config['email'] = str(config.get('email', ''))
db_config['api_key'] = str(config.get('api_key', ''))
elif provider == 'censys':
db_config['api_id'] = str(config.get('api_id', ''))
db_config['api_secret'] = str(config.get('api_secret', ''))
else:
db_config['api_key'] = str(config.get('api_key', ''))
result[provider] = db_config
return result
def to_representation(self, instance):
"""输出数据数据库格式camel-case 中间件会自动转换)"""
if isinstance(instance, dict):
return instance
return instance.providers if hasattr(instance, 'providers') else {}

View File

@@ -0,0 +1,138 @@
"""Subfinder Provider 配置文件生成服务
负责生成 subfinder 的 provider-config.yaml 配置文件
"""
import logging
import os
from pathlib import Path
from typing import Optional
import yaml
from ..models import SubfinderProviderSettings
logger = logging.getLogger(__name__)
class SubfinderProviderConfigService:
"""Subfinder Provider 配置文件生成服务"""
# Provider 格式定义
PROVIDER_FORMATS = {
'fofa': {'type': 'composite', 'format': '{email}:{api_key}'},
'censys': {'type': 'composite', 'format': '{api_id}:{api_secret}'},
'hunter': {'type': 'single', 'field': 'api_key'},
'shodan': {'type': 'single', 'field': 'api_key'},
'zoomeye': {'type': 'single', 'field': 'api_key'},
'securitytrails': {'type': 'single', 'field': 'api_key'},
'threatbook': {'type': 'single', 'field': 'api_key'},
'quake': {'type': 'single', 'field': 'api_key'},
}
def generate(self, output_dir: str) -> Optional[str]:
"""
生成 provider-config.yaml 文件
Args:
output_dir: 输出目录路径
Returns:
生成的配置文件路径,如果没有启用的 provider 则返回 None
"""
settings = SubfinderProviderSettings.get_instance()
config = {}
has_enabled = False
for provider, format_info in self.PROVIDER_FORMATS.items():
provider_config = settings.providers.get(provider, {})
if not provider_config.get('enabled'):
config[provider] = []
continue
value = self._build_provider_value(provider, provider_config)
if value:
config[provider] = [value] # 单个 key 放入数组
has_enabled = True
logger.debug(f"Provider {provider} 已启用")
else:
config[provider] = []
# 检查是否有任何启用的 provider
if not has_enabled:
logger.info("没有启用的 Provider跳过配置文件生成")
return None
# 确保输出目录存在
output_path = Path(output_dir) / 'provider-config.yaml'
output_path.parent.mkdir(parents=True, exist_ok=True)
# 写入 YAML 文件(使用默认列表格式,和 subfinder 一致)
with open(output_path, 'w', encoding='utf-8') as f:
yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
# 设置文件权限为 600仅所有者可读写
os.chmod(output_path, 0o600)
logger.info(f"Provider 配置文件已生成: {output_path}")
return str(output_path)
def _build_provider_value(self, provider: str, config: dict) -> Optional[str]:
"""根据 provider 格式规则构建配置值
Args:
provider: provider 名称
config: provider 配置字典
Returns:
构建的配置值字符串,如果配置不完整则返回 None
"""
format_info = self.PROVIDER_FORMATS.get(provider)
if not format_info:
return None
if format_info['type'] == 'composite':
# 复合格式:需要多个字段
format_str = format_info['format']
try:
# 提取格式字符串中的字段名
# 例如 '{email}:{api_key}' -> ['email', 'api_key']
import re
fields = re.findall(r'\{(\w+)\}', format_str)
# 检查所有字段是否都有值
values = {}
for field in fields:
value = config.get(field, '').strip()
if not value:
logger.debug(f"Provider {provider} 缺少字段 {field}")
return None
values[field] = value
return format_str.format(**values)
except (KeyError, ValueError) as e:
logger.warning(f"构建 {provider} 配置值失败: {e}")
return None
else:
# 单字段格式
field = format_info['field']
value = config.get(field, '').strip()
if not value:
logger.debug(f"Provider {provider} 缺少字段 {field}")
return None
return value
def cleanup(self, config_path: str) -> None:
"""清理配置文件
Args:
config_path: 配置文件路径
"""
try:
if config_path and Path(config_path).exists():
Path(config_path).unlink()
logger.debug(f"已清理配置文件: {config_path}")
except Exception as e:
logger.warning(f"清理配置文件失败: {config_path} - {e}")

View File

@@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ScanViewSet, ScheduledScanViewSet, ScanLogListView
from .views import ScanViewSet, ScheduledScanViewSet, ScanLogListView, SubfinderProviderSettingsView
from .notifications.views import notification_callback
from apps.asset.views import (
SubdomainSnapshotViewSet, WebsiteSnapshotViewSet, DirectorySnapshotViewSet,
@@ -31,6 +31,8 @@ urlpatterns = [
path('', include(router.urls)),
# Worker 回调 API
path('callbacks/notification/', notification_callback, name='notification-callback'),
# API Key 配置
path('settings/api-keys/', SubfinderProviderSettingsView.as_view(), name='subfinder-provider-settings'),
# 扫描日志 API
path('scans/<int:scan_id>/logs/', ScanLogListView.as_view(), name='scan-logs-list'),
# 嵌套路由:/api/scans/{scan_pk}/xxx/

View File

@@ -48,7 +48,7 @@ ENABLE_COMMAND_LOGGING = getattr(settings, 'ENABLE_COMMAND_LOGGING', True)
# 动态并发控制阈值(可在 Django settings 中覆盖)
SCAN_CPU_HIGH = getattr(settings, 'SCAN_CPU_HIGH', 90.0) # CPU 高水位(百分比)
SCAN_MEM_HIGH = getattr(settings, 'SCAN_MEM_HIGH', 80.0) # 内存高水位(百分比)
SCAN_LOAD_CHECK_INTERVAL = getattr(settings, 'SCAN_LOAD_CHECK_INTERVAL', 30) # 负载检查间隔(秒)
SCAN_LOAD_CHECK_INTERVAL = getattr(settings, 'SCAN_LOAD_CHECK_INTERVAL', 180) # 负载检查间隔(秒)
SCAN_COMMAND_STARTUP_DELAY = getattr(settings, 'SCAN_COMMAND_STARTUP_DELAY', 5) # 命令启动前等待(秒)
_ACTIVE_COMMANDS = 0
@@ -74,7 +74,7 @@ def _wait_for_system_load() -> None:
return
logger.info(
"系统负载较高,暂缓启动: cpu=%.1f%% (阈值 %.1f%%), mem=%.1f%% (阈值 %.1f%%)",
"系统负载较高,任务将排队执行防止oom: cpu=%.1f%% (阈值 %.1f%%), mem=%.1f%% (阈值 %.1f%%)",
cpu,
SCAN_CPU_HIGH,
mem,

View File

@@ -3,9 +3,11 @@
from .scan_views import ScanViewSet
from .scheduled_scan_views import ScheduledScanViewSet
from .scan_log_views import ScanLogListView
from .subfinder_provider_settings_views import SubfinderProviderSettingsView
__all__ = [
'ScanViewSet',
'ScheduledScanViewSet',
'ScanLogListView',
'SubfinderProviderSettingsView',
]

View File

@@ -0,0 +1,38 @@
"""Subfinder Provider 配置视图"""
import logging
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from ..models import SubfinderProviderSettings
from ..serializers import SubfinderProviderSettingsSerializer
logger = logging.getLogger(__name__)
class SubfinderProviderSettingsView(APIView):
"""Subfinder Provider 配置视图
GET /api/settings/api-keys/ - 获取配置
PUT /api/settings/api-keys/ - 更新配置
"""
def get(self, request):
"""获取 Subfinder Provider 配置"""
settings = SubfinderProviderSettings.get_instance()
serializer = SubfinderProviderSettingsSerializer(settings.providers)
return Response(serializer.data)
def put(self, request):
"""更新 Subfinder Provider 配置"""
serializer = SubfinderProviderSettingsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
settings = SubfinderProviderSettings.get_instance()
settings.providers.update(serializer.validated_data)
settings.save()
logger.info("Subfinder Provider 配置已更新")
return Response(SubfinderProviderSettingsSerializer(settings.providers).data)

View File

@@ -0,0 +1,306 @@
"use client"
import React, { useState, useEffect } from 'react'
import { IconEye, IconEyeOff, IconWorldSearch, IconRadar2 } from '@tabler/icons-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useApiKeySettings, useUpdateApiKeySettings } from '@/hooks/use-api-key-settings'
import type { ApiKeySettings } from '@/types/api-key-settings.types'
// 密码输入框组件(带显示/隐藏切换)
function PasswordInput({ value, onChange, placeholder, disabled }: {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
}) {
const [show, setShow] = useState(false)
return (
<div className="relative">
<Input
type={show ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="pr-10"
/>
<button
type="button"
onClick={() => setShow(!show)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{show ? <IconEyeOff className="h-4 w-4" /> : <IconEye className="h-4 w-4" />}
</button>
</div>
)
}
// Provider 配置定义
const PROVIDERS = [
{
key: 'fofa',
name: 'FOFA',
description: '网络空间测绘平台,提供全球互联网资产搜索',
icon: IconWorldSearch,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
fields: [
{ name: 'email', label: '邮箱', type: 'text', placeholder: 'your@email.com' },
{ name: 'apiKey', label: 'API Key', type: 'password', placeholder: '输入 FOFA API Key' },
],
docUrl: 'https://fofa.info/api',
},
{
key: 'hunter',
name: 'Hunter (鹰图)',
description: '奇安信威胁情报平台,提供网络空间资产测绘',
icon: IconRadar2,
color: 'text-orange-500',
bgColor: 'bg-orange-500/10',
fields: [
{ name: 'apiKey', label: 'API Key', type: 'password', placeholder: '输入 Hunter API Key' },
],
docUrl: 'https://hunter.qianxin.com/',
},
{
key: 'shodan',
name: 'Shodan',
description: '全球最大的互联网设备搜索引擎',
icon: IconWorldSearch,
color: 'text-red-500',
bgColor: 'bg-red-500/10',
fields: [
{ name: 'apiKey', label: 'API Key', type: 'password', placeholder: '输入 Shodan API Key' },
],
docUrl: 'https://developer.shodan.io/',
},
{
key: 'censys',
name: 'Censys',
description: '互联网资产搜索和监控平台',
icon: IconWorldSearch,
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
fields: [
{ name: 'apiId', label: 'API ID', type: 'text', placeholder: '输入 Censys API ID' },
{ name: 'apiSecret', label: 'API Secret', type: 'password', placeholder: '输入 Censys API Secret' },
],
docUrl: 'https://search.censys.io/api',
},
{
key: 'zoomeye',
name: 'ZoomEye (钟馗之眼)',
description: '知道创宇网络空间搜索引擎',
icon: IconWorldSearch,
color: 'text-green-500',
bgColor: 'bg-green-500/10',
fields: [
{ name: 'apiKey', label: 'API Key', type: 'password', placeholder: '输入 ZoomEye API Key' },
],
docUrl: 'https://www.zoomeye.org/doc',
},
{
key: 'securitytrails',
name: 'SecurityTrails',
description: 'DNS 历史记录和子域名数据平台',
icon: IconWorldSearch,
color: 'text-cyan-500',
bgColor: 'bg-cyan-500/10',
fields: [
{ name: 'apiKey', label: 'API Key', type: 'password', placeholder: '输入 SecurityTrails API Key' },
],
docUrl: 'https://securitytrails.com/corp/api',
},
{
key: 'threatbook',
name: 'ThreatBook (微步在线)',
description: '威胁情报平台,提供域名和 IP 情报查询',
icon: IconWorldSearch,
color: 'text-indigo-500',
bgColor: 'bg-indigo-500/10',
fields: [
{ name: 'apiKey', label: 'API Key', type: 'password', placeholder: '输入 ThreatBook API Key' },
],
docUrl: 'https://x.threatbook.com/api',
},
{
key: 'quake',
name: 'Quake (360)',
description: '360 网络空间测绘系统',
icon: IconWorldSearch,
color: 'text-teal-500',
bgColor: 'bg-teal-500/10',
fields: [
{ name: 'apiKey', label: 'API Key', type: 'password', placeholder: '输入 Quake API Key' },
],
docUrl: 'https://quake.360.net/quake/#/help',
},
]
// 默认配置
const DEFAULT_SETTINGS: ApiKeySettings = {
fofa: { enabled: false, email: '', apiKey: '' },
hunter: { enabled: false, apiKey: '' },
shodan: { enabled: false, apiKey: '' },
censys: { enabled: false, apiId: '', apiSecret: '' },
zoomeye: { enabled: false, apiKey: '' },
securitytrails: { enabled: false, apiKey: '' },
threatbook: { enabled: false, apiKey: '' },
quake: { enabled: false, apiKey: '' },
}
export default function ApiKeysSettingsPage() {
const { data: settings, isLoading } = useApiKeySettings()
const updateMutation = useUpdateApiKeySettings()
const [formData, setFormData] = useState<ApiKeySettings>(DEFAULT_SETTINGS)
const [hasChanges, setHasChanges] = useState(false)
// 当数据加载完成后,更新表单数据
useEffect(() => {
if (settings) {
setFormData({ ...DEFAULT_SETTINGS, ...settings })
setHasChanges(false)
}
}, [settings])
const updateProvider = (providerKey: string, field: string, value: any) => {
setFormData(prev => ({
...prev,
[providerKey]: {
...prev[providerKey as keyof ApiKeySettings],
[field]: value,
}
}))
setHasChanges(true)
}
const handleSave = async () => {
updateMutation.mutate(formData)
setHasChanges(false)
}
const enabledCount = Object.values(formData).filter((p: any) => p?.enabled).length
if (isLoading) {
return (
<div className="p-4 md:p-6 space-y-6">
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96 mt-2" />
</div>
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</div>
)
}
return (
<div className="p-4 md:p-6 space-y-6">
{/* 页面标题 */}
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold">API </h1>
{enabledCount > 0 && (
<Badge variant="secondary">{enabledCount} </Badge>
)}
</div>
<p className="text-muted-foreground mt-1">
API subfinder 使
</p>
</div>
{/* Provider 卡片列表 */}
<div className="grid gap-4">
{PROVIDERS.map((provider) => {
const data = formData[provider.key as keyof ApiKeySettings] || {}
const isEnabled = (data as any)?.enabled || false
return (
<Card key={provider.key}>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${provider.bgColor}`}>
<provider.icon className={`h-5 w-5 ${provider.color}`} />
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-base">{provider.name}</CardTitle>
{isEnabled && <Badge variant="outline" className="text-xs text-green-600"></Badge>}
</div>
<CardDescription>{provider.description}</CardDescription>
</div>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => updateProvider(provider.key, 'enabled', checked)}
/>
</div>
</CardHeader>
{/* 展开的配置表单 */}
{isEnabled && (
<CardContent className="pt-0">
<Separator className="mb-4" />
<div className="space-y-4">
{provider.fields.map((field) => (
<div key={field.name} className="space-y-2">
<label className="text-sm font-medium">{field.label}</label>
{field.type === 'password' ? (
<PasswordInput
value={(data as any)[field.name] || ''}
onChange={(value) => updateProvider(provider.key, field.name, value)}
placeholder={field.placeholder}
/>
) : (
<Input
type="text"
value={(data as any)[field.name] || ''}
onChange={(e) => updateProvider(provider.key, field.name, e.target.value)}
placeholder={field.placeholder}
/>
)}
</div>
))}
<p className="text-xs text-muted-foreground">
API Key
<a
href={provider.docUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline ml-1"
>
{provider.docUrl}
</a>
</p>
</div>
</CardContent>
)}
</Card>
)
})}
</div>
{/* 保存按钮 */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={updateMutation.isPending || !hasChanges}
>
{updateMutation.isPending ? '保存中...' : '保存配置'}
</Button>
</div>
</div>
)
}

View File

@@ -17,6 +17,7 @@ import {
IconBug, // Vulnerability icon
IconMessageReport, // Feedback icon
IconSearch, // Search icon
IconKey, // API Key icon
} from "@tabler/icons-react"
// Import internationalization hook
import { useTranslations } from 'next-intl'
@@ -168,6 +169,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: "/settings/notifications/",
icon: IconSettings,
},
{
name: t('apiKeys'),
url: "/settings/api-keys/",
icon: IconKey,
},
]
return (

View File

@@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiKeySettingsService } from '@/services/api-key-settings.service'
import type { ApiKeySettings } from '@/types/api-key-settings.types'
import { useToastMessages } from '@/lib/toast-helpers'
import { getErrorCode } from '@/lib/response-parser'
export function useApiKeySettings() {
return useQuery({
queryKey: ['api-key-settings'],
queryFn: () => ApiKeySettingsService.getSettings(),
})
}
export function useUpdateApiKeySettings() {
const qc = useQueryClient()
const toastMessages = useToastMessages()
return useMutation({
mutationFn: (data: Partial<ApiKeySettings>) =>
ApiKeySettingsService.updateSettings(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['api-key-settings'] })
toastMessages.success('toast.apiKeys.settings.success')
},
onError: (error: any) => {
toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.apiKeys.settings.error')
},
})
}

View File

@@ -319,6 +319,7 @@
"workers": "Workers",
"systemLogs": "System Logs",
"notifications": "Notifications",
"apiKeys": "API Keys",
"help": "Get Help",
"feedback": "Feedback"
},
@@ -1690,6 +1691,12 @@
"error": "Notification connection error: {message}"
}
},
"apiKeys": {
"settings": {
"success": "API key settings saved",
"error": "Failed to save API key settings"
}
},
"tool": {
"create": {
"success": "Tool created successfully",

View File

@@ -319,6 +319,7 @@
"workers": "扫描节点",
"systemLogs": "系统日志",
"notifications": "通知设置",
"apiKeys": "API 密钥",
"help": "获取帮助",
"feedback": "反馈建议"
},
@@ -1690,6 +1691,12 @@
"error": "通知连接错误: {message}"
}
},
"apiKeys": {
"settings": {
"success": "API 密钥配置已保存",
"error": "保存 API 密钥配置失败"
}
},
"tool": {
"create": {
"success": "工具创建成功",

View File

@@ -0,0 +1,187 @@
import type { Directory, DirectoryListResponse } from '@/types/directory.types'
export const mockDirectories: Directory[] = [
{
id: 1,
url: 'https://acme.com/admin',
status: 200,
contentLength: 12345,
words: 1234,
lines: 89,
contentType: 'text/html',
duration: 0.234,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
url: 'https://acme.com/api',
status: 301,
contentLength: 0,
words: 0,
lines: 0,
contentType: 'text/html',
duration: 0.056,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
url: 'https://acme.com/login',
status: 200,
contentLength: 8765,
words: 567,
lines: 45,
contentType: 'text/html',
duration: 0.189,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
url: 'https://acme.com/dashboard',
status: 302,
contentLength: 0,
words: 0,
lines: 0,
contentType: 'text/html',
duration: 0.078,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
url: 'https://acme.com/static/js/app.js',
status: 200,
contentLength: 456789,
words: 12345,
lines: 5678,
contentType: 'application/javascript',
duration: 0.345,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:04:00Z',
},
{
id: 6,
url: 'https://acme.com/.git/config',
status: 200,
contentLength: 234,
words: 45,
lines: 12,
contentType: 'text/plain',
duration: 0.023,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:05:00Z',
},
{
id: 7,
url: 'https://acme.com/backup.zip',
status: 200,
contentLength: 12345678,
words: null,
lines: null,
contentType: 'application/zip',
duration: 1.234,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:06:00Z',
},
{
id: 8,
url: 'https://acme.com/robots.txt',
status: 200,
contentLength: 567,
words: 89,
lines: 23,
contentType: 'text/plain',
duration: 0.034,
websiteUrl: 'https://acme.com',
createdAt: '2024-12-28T10:07:00Z',
},
{
id: 9,
url: 'https://api.acme.com/v1/health',
status: 200,
contentLength: 45,
words: 5,
lines: 1,
contentType: 'application/json',
duration: 0.012,
websiteUrl: 'https://api.acme.com',
createdAt: '2024-12-28T10:08:00Z',
},
{
id: 10,
url: 'https://api.acme.com/swagger-ui.html',
status: 200,
contentLength: 23456,
words: 1234,
lines: 234,
contentType: 'text/html',
duration: 0.267,
websiteUrl: 'https://api.acme.com',
createdAt: '2024-12-28T10:09:00Z',
},
{
id: 11,
url: 'https://techstart.io/wp-admin',
status: 302,
contentLength: 0,
words: 0,
lines: 0,
contentType: 'text/html',
duration: 0.089,
websiteUrl: 'https://techstart.io',
createdAt: '2024-12-26T08:45:00Z',
},
{
id: 12,
url: 'https://techstart.io/wp-login.php',
status: 200,
contentLength: 4567,
words: 234,
lines: 78,
contentType: 'text/html',
duration: 0.156,
websiteUrl: 'https://techstart.io',
createdAt: '2024-12-26T08:46:00Z',
},
]
export function getMockDirectories(params?: {
page?: number
pageSize?: number
filter?: string
targetId?: number
scanId?: number
}): DirectoryListResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockDirectories
if (filter) {
filtered = filtered.filter(
d =>
d.url.toLowerCase().includes(filter) ||
d.contentType.toLowerCase().includes(filter)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return {
results,
total,
page,
pageSize,
totalPages,
}
}
export function getMockDirectoryById(id: number): Directory | undefined {
return mockDirectories.find(d => d.id === id)
}

View File

@@ -0,0 +1,593 @@
import type {
EholeFingerprint,
GobyFingerprint,
WappalyzerFingerprint,
FingersFingerprint,
FingerPrintHubFingerprint,
ARLFingerprint,
FingerprintStats,
} from '@/types/fingerprint.types'
import type { PaginatedResponse } from '@/types/api-response.types'
// ==================== EHole 指纹数据(真实数据示例)====================
export const mockEholeFingerprints: EholeFingerprint[] = [
{
id: 1,
cms: '致远OA',
method: 'keyword',
location: 'body',
keyword: ['/seeyon/USER-DATA/IMAGES/LOGIN/login.gif'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
cms: '通达OA',
method: 'keyword',
location: 'body',
keyword: ['/static/images/tongda.ico'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
cms: 'Nexus Repository Manager',
method: 'keyword',
location: 'title',
keyword: ['Nexus Repository Manager'],
isImportant: true,
type: 'cloud',
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
cms: '禅道 zentao',
method: 'keyword',
location: 'title',
keyword: ['Welcome to use zentao'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
cms: 'Kibana',
method: 'keyword',
location: 'title',
keyword: ['Kibana'],
isImportant: true,
type: 'cloud',
createdAt: '2024-12-20T10:04:00Z',
},
{
id: 6,
cms: 'Spring env',
method: 'keyword',
location: 'body',
keyword: ['Whitelabel Error Page'],
isImportant: true,
type: 'framework',
createdAt: '2024-12-20T10:05:00Z',
},
{
id: 7,
cms: '泛微OA',
method: 'keyword',
location: 'header',
keyword: ['ecology_JSessionid'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:06:00Z',
},
{
id: 8,
cms: '用友NC',
method: 'keyword',
location: 'body',
keyword: ['UFIDA', '/nc/servlet/nc.ui.iufo.login.Index'],
isImportant: true,
type: 'oa',
createdAt: '2024-12-20T10:07:00Z',
},
]
// ==================== Goby 指纹数据(真实数据示例)====================
export const mockGobyFingerprints: GobyFingerprint[] = [
{
id: 1,
name: 'WebSphere-App-Server',
logic: '((a||b) &&c&&d) || (e&&f&&g)',
rule: [
{ label: 'a', feature: 'Server: WebSphere Application Server', is_equal: true },
{ label: 'b', feature: 'IBM WebSphere Application Server', is_equal: true },
{ label: 'c', feature: 'couchdb', is_equal: false },
{ label: 'd', feature: 'drupal', is_equal: false },
{ label: 'e', feature: 'Server: WebSphere Application Server', is_equal: true },
{ label: 'f', feature: 'couchdb', is_equal: false },
{ label: 'g', feature: 'drupal', is_equal: false },
],
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
name: 'Wing-FTP-Server',
logic: 'a||b||c||d',
rule: [
{ label: 'a', feature: 'Server: Wing FTP Server', is_equal: true },
{ label: 'b', feature: 'Server: Wing FTP Server', is_equal: true },
{ label: 'c', feature: '/help_javascript.htm', is_equal: true },
{ label: 'd', feature: 'Wing FTP Server', is_equal: true },
],
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
name: 'Fortinet-sslvpn',
logic: 'a&&b',
rule: [
{ label: 'a', feature: 'fgt_lang', is_equal: true },
{ label: 'b', feature: '/sslvpn/portal.html', is_equal: true },
],
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
name: 'D-link-DSL-2640B',
logic: 'a||b',
rule: [
{ label: 'a', feature: 'Product : DSL-2640B', is_equal: true },
{ label: 'b', feature: 'D-Link DSL-2640B', is_equal: true },
],
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
name: 'Kedacom-NVR',
logic: 'a|| (b&&c) ||d',
rule: [
{ label: 'a', feature: 'NVR Station Web', is_equal: true },
{ label: 'b', feature: 'location="index_cn.htm";', is_equal: true },
{ label: 'c', feature: 'if(syslan == "zh-cn"', is_equal: true },
{ label: 'd', feature: 'WMS browse NVR', is_equal: true },
],
createdAt: '2024-12-20T10:04:00Z',
},
]
// ==================== Wappalyzer 指纹数据(真实数据示例)====================
export const mockWappalyzerFingerprints: WappalyzerFingerprint[] = [
{
id: 1,
name: '1C-Bitrix',
cats: [1, 6],
cookies: { bitrix_sm_guest_id: '', bitrix_sm_last_ip: '', bitrix_sm_sale_uid: '' },
headers: { 'set-cookie': 'bitrix_', 'x-powered-cms': 'bitrix site manager' },
scriptSrc: ['bitrix(?:\\.info/|/js/main/core)'],
js: [],
implies: ['PHP'],
meta: {},
html: [],
description: '1C-Bitrix is a system of web project management.',
website: 'https://www.1c-bitrix.ru',
cpe: '',
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
name: 'React',
cats: [12],
cookies: {},
headers: {},
scriptSrc: ['react(?:-dom)?(?:\\.min)?\\.js'],
js: ['React.version'],
implies: [],
meta: {},
html: ['data-reactroot'],
description: 'React is a JavaScript library for building user interfaces.',
website: 'https://reactjs.org',
cpe: 'cpe:/a:facebook:react',
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
name: 'Vue.js',
cats: [12],
cookies: {},
headers: {},
scriptSrc: ['vue(?:\\.min)?\\.js'],
js: ['Vue.version'],
implies: [],
meta: {},
html: ['data-v-'],
description: 'Vue.js is a progressive JavaScript framework.',
website: 'https://vuejs.org',
cpe: 'cpe:/a:vuejs:vue',
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
name: 'nginx',
cats: [22],
cookies: {},
headers: { server: 'nginx(?:/([\\d.]+))?\\;version:\\1' },
scriptSrc: [],
js: [],
implies: [],
meta: {},
html: [],
description: 'nginx is a web server.',
website: 'http://nginx.org/en',
cpe: 'cpe:/a:nginx:nginx',
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
name: 'WordPress',
cats: [1, 11],
cookies: {},
headers: { 'x-pingback': '/xmlrpc\\.php$' },
scriptSrc: ['/wp-(?:content|includes)/'],
js: [],
implies: ['PHP', 'MySQL'],
meta: { generator: ['WordPress(?: ([\\d.]+))?\\;version:\\1'] },
html: ['<link rel=["\']stylesheet["\'] [^>]+/wp-(?:content|includes)/'],
description: 'WordPress is a free and open-source CMS.',
website: 'https://wordpress.org',
cpe: 'cpe:/a:wordpress:wordpress',
createdAt: '2024-12-20T10:04:00Z',
},
]
// ==================== Fingers 指纹数据(真实数据示例)====================
export const mockFingersFingerprints: FingersFingerprint[] = [
{
id: 1,
name: 'jenkins',
link: '',
rule: [
{
favicon_hash: ['81586312'],
body: 'Jenkins',
header: 'X-Jenkins',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [8080],
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
name: 'gitlab',
link: '',
rule: [
{
favicon_hash: ['516963061', '1278323681'],
body: 'GitLab',
header: '_gitlab_session',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [80, 443],
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
name: 'nacos',
link: '',
rule: [
{
body: '<title>Nacos</title>',
send_data: '/nacos/',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [8848],
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
name: 'elasticsearch',
link: '',
rule: [
{
body: '"cluster_name" : "elasticsearch"',
vuln: 'elasticsearch_unauth',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [9200],
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
name: 'zabbix',
link: '',
rule: [
{
favicon_hash: ['892542951'],
body: 'images/general/zabbix.ico',
header: 'zbx_sessionid',
send_data: '/zabbix',
},
],
tag: ['cloud'],
focus: true,
defaultPort: [80, 443],
createdAt: '2024-12-20T10:04:00Z',
},
]
// ==================== FingerPrintHub 指纹数据(真实数据示例)====================
export const mockFingerPrintHubFingerprints: FingerPrintHubFingerprint[] = [
{
id: 1,
fpId: 'apache-tomcat',
name: 'Apache Tomcat',
author: 'pdteam',
tags: 'tech,apache,tomcat',
severity: 'info',
metadata: {
product: 'tomcat',
vendor: 'apache',
verified: true,
shodan_query: 'http.favicon.hash:"-297069493"',
fofa_query: 'app="Apache-Tomcat"',
},
http: [
{
method: 'GET',
path: '/',
matchers: [
{ type: 'word', part: 'body', words: ['Apache Tomcat'] },
{ type: 'status', status: [200] },
],
},
],
sourceFile: 'http/technologies/apache/apache-tomcat.yaml',
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
fpId: 'nginx-detect',
name: 'Nginx Server',
author: 'pdteam',
tags: 'tech,nginx',
severity: 'info',
metadata: {
product: 'nginx',
vendor: 'nginx',
verified: true,
},
http: [
{
method: 'GET',
path: '/',
matchers: [
{ type: 'regex', part: 'header', regex: ['[Nn]ginx'] },
],
extractors: [
{ type: 'regex', part: 'header', regex: ['nginx/([\\d.]+)'], group: 1 },
],
},
],
sourceFile: 'http/technologies/nginx/nginx-version.yaml',
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
fpId: 'spring-boot-detect',
name: 'Spring Boot',
author: 'pdteam',
tags: 'tech,spring,java',
severity: 'info',
metadata: {
product: 'spring-boot',
vendor: 'vmware',
verified: true,
},
http: [
{
method: 'GET',
path: '/',
matchers: [
{ type: 'word', part: 'body', words: ['Whitelabel Error Page'] },
],
},
],
sourceFile: 'http/technologies/spring/spring-boot.yaml',
createdAt: '2024-12-20T10:02:00Z',
},
]
// ==================== ARL 指纹数据(真实数据示例)====================
export const mockARLFingerprints: ARLFingerprint[] = [
{
id: 1,
name: 'Shiro',
rule: 'header="rememberMe="',
createdAt: '2024-12-20T10:00:00Z',
},
{
id: 2,
name: 'ThinkPHP',
rule: 'body="ThinkPHP" || header="ThinkPHP"',
createdAt: '2024-12-20T10:01:00Z',
},
{
id: 3,
name: 'Fastjson',
rule: 'body="fastjson" || body="com.alibaba.fastjson"',
createdAt: '2024-12-20T10:02:00Z',
},
{
id: 4,
name: 'Weblogic',
rule: 'body="WebLogic" || header="WebLogic" || body="bea_wls_internal"',
createdAt: '2024-12-20T10:03:00Z',
},
{
id: 5,
name: 'JBoss',
rule: 'body="JBoss" || header="JBoss" || body="jboss.css"',
createdAt: '2024-12-20T10:04:00Z',
},
{
id: 6,
name: 'Struts2',
rule: 'body=".action" || body="struts"',
createdAt: '2024-12-20T10:05:00Z',
},
]
// ==================== 统计数据 ====================
export const mockFingerprintStats: FingerprintStats = {
ehole: 1892,
goby: 4567,
wappalyzer: 3456,
fingers: 2345,
fingerprinthub: 8901,
arl: 1234,
}
// ==================== 查询函数 ====================
export function getMockEholeFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<EholeFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockEholeFingerprints
if (filter) {
filtered = filtered.filter(f => f.cms.toLowerCase().includes(filter))
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return { results, total, page, pageSize, totalPages }
}
export function getMockGobyFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<GobyFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockGobyFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return { results, total, page, pageSize, totalPages }
}
export function getMockWappalyzerFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<WappalyzerFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockWappalyzerFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return { results, total, page, pageSize, totalPages }
}
export function getMockFingersFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<FingersFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockFingersFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return { results, total, page, pageSize, totalPages }
}
export function getMockFingerPrintHubFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<FingerPrintHubFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockFingerPrintHubFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return { results, total, page, pageSize, totalPages }
}
export function getMockARLFingerprints(params?: {
page?: number
pageSize?: number
filter?: string
}): PaginatedResponse<ARLFingerprint> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockARLFingerprints
if (filter) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(filter))
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return { results, total, page, pageSize, totalPages }
}
export function getMockFingerprintStats(): FingerprintStats {
return mockFingerprintStats
}

View File

@@ -0,0 +1,118 @@
import type { IPAddress, GetIPAddressesResponse } from '@/types/ip-address.types'
// 使用函数生成IP地址
const ip = (a: number, b: number, c: number, d: number) => `${a}.${b}.${c}.${d}`
export const mockIPAddresses: IPAddress[] = [
{
ip: ip(192, 0, 2, 1),
hosts: ['router.local', 'gateway.lan'],
ports: [80, 443, 22, 53],
createdAt: '2024-12-28T10:00:00Z',
},
{
ip: ip(192, 0, 2, 10),
hosts: ['api.acme.com', 'backend.acme.com'],
ports: [80, 443, 8080, 3306],
createdAt: '2024-12-28T10:01:00Z',
},
{
ip: ip(192, 0, 2, 11),
hosts: ['web.acme.com', 'www.acme.com'],
ports: [80, 443],
createdAt: '2024-12-28T10:02:00Z',
},
{
ip: ip(198, 51, 100, 50),
hosts: ['db.internal.acme.com'],
ports: [3306, 5432, 27017],
createdAt: '2024-12-28T10:03:00Z',
},
{
ip: ip(203, 0, 113, 50),
hosts: ['cdn.acme.com'],
ports: [80, 443],
createdAt: '2024-12-28T10:04:00Z',
},
{
ip: ip(198, 51, 100, 10),
hosts: ['mail.acme.com', 'smtp.acme.com'],
ports: [25, 465, 587, 993, 995],
createdAt: '2024-12-28T10:05:00Z',
},
{
ip: ip(192, 0, 2, 100),
hosts: ['jenkins.acme.com'],
ports: [8080, 50000],
createdAt: '2024-12-28T10:06:00Z',
},
{
ip: ip(192, 0, 2, 101),
hosts: ['gitlab.acme.com'],
ports: [80, 443, 22],
createdAt: '2024-12-28T10:07:00Z',
},
{
ip: ip(192, 0, 2, 102),
hosts: ['k8s.acme.com', 'kubernetes.acme.com'],
ports: [6443, 10250, 10251, 10252],
createdAt: '2024-12-28T10:08:00Z',
},
{
ip: ip(192, 0, 2, 103),
hosts: ['elastic.acme.com'],
ports: [9200, 9300, 5601],
createdAt: '2024-12-28T10:09:00Z',
},
{
ip: ip(192, 0, 2, 104),
hosts: ['redis.acme.com'],
ports: [6379],
createdAt: '2024-12-28T10:10:00Z',
},
{
ip: ip(192, 0, 2, 105),
hosts: ['mq.acme.com', 'rabbitmq.acme.com'],
ports: [5672, 15672],
createdAt: '2024-12-28T10:11:00Z',
},
]
export function getMockIPAddresses(params?: {
page?: number
pageSize?: number
filter?: string
targetId?: number
scanId?: number
}): GetIPAddressesResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const filter = params?.filter?.toLowerCase() || ''
let filtered = mockIPAddresses
if (filter) {
filtered = filtered.filter(
ipAddr =>
ipAddr.ip.toLowerCase().includes(filter) ||
ipAddr.hosts.some(h => h.toLowerCase().includes(filter))
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = filtered.slice(start, start + pageSize)
return {
results,
total,
page,
pageSize,
totalPages,
}
}
export function getMockIPAddressByIP(ipStr: string): IPAddress | undefined {
return mockIPAddresses.find(addr => addr.ip === ipStr)
}

View File

@@ -0,0 +1,35 @@
import type {
NotificationSettings,
GetNotificationSettingsResponse,
UpdateNotificationSettingsResponse,
} from '@/types/notification-settings.types'
export const mockNotificationSettings: NotificationSettings = {
discord: {
enabled: true,
webhookUrl: 'https://discord.com/api/webhooks/1234567890/abcdefghijklmnop',
},
categories: {
scan: true,
vulnerability: true,
asset: true,
system: false,
},
}
export function getMockNotificationSettings(): GetNotificationSettingsResponse {
return mockNotificationSettings
}
export function updateMockNotificationSettings(
settings: NotificationSettings
): UpdateNotificationSettingsResponse {
// 模拟更新设置
Object.assign(mockNotificationSettings, settings)
return {
message: 'Notification settings updated successfully',
discord: mockNotificationSettings.discord,
categories: mockNotificationSettings.categories,
}
}

View File

@@ -0,0 +1,240 @@
import type {
NucleiTemplateTreeNode,
NucleiTemplateTreeResponse,
NucleiTemplateContent,
} from '@/types/nuclei.types'
export const mockNucleiTemplateTree: NucleiTemplateTreeNode[] = [
{
type: 'folder',
name: 'cves',
path: 'cves',
children: [
{
type: 'folder',
name: '2024',
path: 'cves/2024',
children: [
{
type: 'file',
name: 'CVE-2024-1234.yaml',
path: 'cves/2024/CVE-2024-1234.yaml',
templateId: 'CVE-2024-1234',
severity: 'critical',
tags: ['cve', 'rce'],
},
{
type: 'file',
name: 'CVE-2024-5678.yaml',
path: 'cves/2024/CVE-2024-5678.yaml',
templateId: 'CVE-2024-5678',
severity: 'high',
tags: ['cve', 'sqli'],
},
],
},
{
type: 'folder',
name: '2023',
path: 'cves/2023',
children: [
{
type: 'file',
name: 'CVE-2023-9876.yaml',
path: 'cves/2023/CVE-2023-9876.yaml',
templateId: 'CVE-2023-9876',
severity: 'high',
tags: ['cve', 'auth-bypass'],
},
],
},
],
},
{
type: 'folder',
name: 'vulnerabilities',
path: 'vulnerabilities',
children: [
{
type: 'folder',
name: 'generic',
path: 'vulnerabilities/generic',
children: [
{
type: 'file',
name: 'sqli-error-based.yaml',
path: 'vulnerabilities/generic/sqli-error-based.yaml',
templateId: 'sqli-error-based',
severity: 'high',
tags: ['sqli', 'generic'],
},
{
type: 'file',
name: 'xss-reflected.yaml',
path: 'vulnerabilities/generic/xss-reflected.yaml',
templateId: 'xss-reflected',
severity: 'medium',
tags: ['xss', 'generic'],
},
],
},
],
},
{
type: 'folder',
name: 'technologies',
path: 'technologies',
children: [
{
type: 'file',
name: 'nginx-version.yaml',
path: 'technologies/nginx-version.yaml',
templateId: 'nginx-version',
severity: 'info',
tags: ['tech', 'nginx'],
},
{
type: 'file',
name: 'apache-detect.yaml',
path: 'technologies/apache-detect.yaml',
templateId: 'apache-detect',
severity: 'info',
tags: ['tech', 'apache'],
},
],
},
{
type: 'folder',
name: 'exposures',
path: 'exposures',
children: [
{
type: 'folder',
name: 'configs',
path: 'exposures/configs',
children: [
{
type: 'file',
name: 'git-config.yaml',
path: 'exposures/configs/git-config.yaml',
templateId: 'git-config',
severity: 'medium',
tags: ['exposure', 'git'],
},
{
type: 'file',
name: 'env-file.yaml',
path: 'exposures/configs/env-file.yaml',
templateId: 'env-file',
severity: 'high',
tags: ['exposure', 'env'],
},
],
},
],
},
]
export const mockNucleiTemplateContent: Record<string, NucleiTemplateContent> = {
'cves/2024/CVE-2024-1234.yaml': {
path: 'cves/2024/CVE-2024-1234.yaml',
name: 'CVE-2024-1234.yaml',
templateId: 'CVE-2024-1234',
severity: 'critical',
tags: ['cve', 'rce'],
content: `id: CVE-2024-1234
info:
name: Example RCE Vulnerability
author: pdteam
severity: critical
description: |
Example remote code execution vulnerability.
reference:
- https://example.com/cve-2024-1234
tags: cve,cve2024,rce
http:
- method: POST
path:
- "{{BaseURL}}/api/execute"
headers:
Content-Type: application/json
body: '{"cmd": "id"}'
matchers:
- type: word
words:
- "uid="
- "gid="
condition: and
`,
},
'vulnerabilities/generic/sqli-error-based.yaml': {
path: 'vulnerabilities/generic/sqli-error-based.yaml',
name: 'sqli-error-based.yaml',
templateId: 'sqli-error-based',
severity: 'high',
tags: ['sqli', 'generic'],
content: `id: sqli-error-based
info:
name: Error Based SQL Injection
author: pdteam
severity: high
tags: sqli,generic
http:
- method: GET
path:
- "{{BaseURL}}/?id=1'"
matchers:
- type: word
words:
- "SQL syntax"
- "mysql_fetch"
- "You have an error"
condition: or
`,
},
'technologies/nginx-version.yaml': {
path: 'technologies/nginx-version.yaml',
name: 'nginx-version.yaml',
templateId: 'nginx-version',
severity: 'info',
tags: ['tech', 'nginx'],
content: `id: nginx-version
info:
name: Nginx Version Detection
author: pdteam
severity: info
tags: tech,nginx
http:
- method: GET
path:
- "{{BaseURL}}/"
matchers:
- type: regex
part: header
regex:
- "nginx/([\\d.]+)"
extractors:
- type: regex
part: header
group: 1
regex:
- "nginx/([\\d.]+)"
`,
},
}
export function getMockNucleiTemplateTree(): NucleiTemplateTreeResponse {
return {
roots: mockNucleiTemplateTree,
}
}
export function getMockNucleiTemplateContent(path: string): NucleiTemplateContent | undefined {
return mockNucleiTemplateContent[path]
}

View File

@@ -0,0 +1,154 @@
import type {
SearchResponse,
WebsiteSearchResult,
EndpointSearchResult,
AssetType,
} from '@/types/search.types'
import { mockWebsites } from './websites'
import { mockEndpoints } from './endpoints'
// 将 Website 转换为搜索结果格式
function websiteToSearchResult(website: typeof mockWebsites[0]): WebsiteSearchResult {
return {
id: website.id,
url: website.url,
host: website.host,
title: website.title,
technologies: website.tech || [],
statusCode: website.statusCode,
contentLength: website.contentLength,
contentType: website.contentType,
webserver: website.webserver,
location: website.location,
vhost: website.vhost,
responseHeaders: {},
responseBody: website.responseBody || '',
createdAt: website.createdAt,
targetId: website.target ?? 1,
vulnerabilities: [],
}
}
// 将 Endpoint 转换为搜索结果格式
function endpointToSearchResult(endpoint: typeof mockEndpoints[0]): EndpointSearchResult {
return {
id: endpoint.id,
url: endpoint.url,
host: endpoint.host || '',
title: endpoint.title,
technologies: endpoint.tech || [],
statusCode: endpoint.statusCode,
contentLength: endpoint.contentLength,
contentType: endpoint.contentType || '',
webserver: endpoint.webserver || '',
location: endpoint.location || '',
vhost: null,
responseHeaders: {},
responseBody: '',
createdAt: endpoint.createdAt ?? null,
targetId: 1,
matchedGfPatterns: endpoint.gfPatterns || [],
}
}
// 解析搜索表达式
function parseSearchQuery(query: string): { field: string; operator: string; value: string }[] {
const conditions: { field: string; operator: string; value: string }[] = []
// 简单解析field="value" 或 field=="value" 或 field!="value"
const regex = /(\w+)(==|!=|=)"([^"]+)"/g
let match
while ((match = regex.exec(query)) !== null) {
conditions.push({
field: match[1],
operator: match[2],
value: match[3],
})
}
return conditions
}
// 检查记录是否匹配条件
function matchesConditions(
record: WebsiteSearchResult | EndpointSearchResult,
conditions: { field: string; operator: string; value: string }[]
): boolean {
if (conditions.length === 0) return true
return conditions.every(cond => {
let fieldValue: string | number | null = null
switch (cond.field) {
case 'host':
fieldValue = record.host
break
case 'url':
fieldValue = record.url
break
case 'title':
fieldValue = record.title
break
case 'tech':
fieldValue = record.technologies.join(',')
break
case 'status':
fieldValue = String(record.statusCode)
break
default:
return true
}
if (fieldValue === null) return false
const strValue = String(fieldValue).toLowerCase()
const searchValue = cond.value.toLowerCase()
switch (cond.operator) {
case '=':
return strValue.includes(searchValue)
case '==':
return strValue === searchValue
case '!=':
return !strValue.includes(searchValue)
default:
return true
}
})
}
export function getMockSearchResults(params: {
q?: string
asset_type?: AssetType
page?: number
pageSize?: number
}): SearchResponse {
const { q = '', asset_type = 'website', page = 1, pageSize = 10 } = params
const conditions = parseSearchQuery(q)
let results: (WebsiteSearchResult | EndpointSearchResult)[]
if (asset_type === 'website') {
results = mockWebsites
.map(websiteToSearchResult)
.filter(r => matchesConditions(r, conditions))
} else {
results = mockEndpoints
.map(endpointToSearchResult)
.filter(r => matchesConditions(r, conditions))
}
const total = results.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const paginatedResults = results.slice(start, start + pageSize)
return {
results: paginatedResults,
total,
page,
pageSize,
totalPages,
assetType: asset_type,
}
}

View File

@@ -0,0 +1,100 @@
import type { SystemLogResponse, LogFilesResponse, LogFile } from '@/types/system-log.types'
export const mockLogFiles: LogFile[] = [
{
filename: 'xingrin.log',
category: 'system',
size: 1234567,
modifiedAt: '2024-12-28T10:00:00Z',
},
{
filename: 'xingrin-error.log',
category: 'error',
size: 45678,
modifiedAt: '2024-12-28T09:30:00Z',
},
{
filename: 'worker.log',
category: 'system',
size: 234567,
modifiedAt: '2024-12-28T10:00:00Z',
},
{
filename: 'celery.log',
category: 'system',
size: 567890,
modifiedAt: '2024-12-28T09:45:00Z',
},
{
filename: 'nginx-access.log',
category: 'system',
size: 12345678,
modifiedAt: '2024-12-28T10:00:00Z',
},
{
filename: 'nginx-error.log',
category: 'error',
size: 23456,
modifiedAt: '2024-12-28T08:00:00Z',
},
]
export const mockSystemLogContent = `[2024-12-28 10:00:00] INFO: Server started on port 8000
[2024-12-28 10:00:01] INFO: Database connection established
[2024-12-28 10:00:02] INFO: Redis connection established
[2024-12-28 10:00:03] INFO: Worker node registered: local-worker-1
[2024-12-28 10:00:05] INFO: Celery worker started with 4 concurrent tasks
[2024-12-28 10:01:00] INFO: New scan task created: scan-001
[2024-12-28 10:01:01] INFO: Task scan-001 assigned to worker local-worker-1
[2024-12-28 10:01:05] INFO: Subdomain enumeration started for target: acme.com
[2024-12-28 10:02:30] INFO: Found 45 subdomains for acme.com
[2024-12-28 10:02:31] INFO: Port scanning started for 45 hosts
[2024-12-28 10:05:00] INFO: Port scanning completed, found 123 open ports
[2024-12-28 10:05:01] INFO: HTTP probing started for 123 endpoints
[2024-12-28 10:08:00] INFO: HTTP probing completed, found 89 live websites
[2024-12-28 10:08:01] INFO: Fingerprint detection started
[2024-12-28 10:10:00] INFO: Fingerprint detection completed
[2024-12-28 10:10:01] INFO: Vulnerability scanning started with nuclei
[2024-12-28 10:15:00] INFO: Vulnerability scanning completed, found 5 vulnerabilities
[2024-12-28 10:15:01] INFO: Scan task scan-001 completed successfully
[2024-12-28 10:15:02] INFO: Results saved to database
[2024-12-28 10:15:03] INFO: Notification sent to Discord webhook`
export const mockErrorLogContent = `[2024-12-28 08:30:00] ERROR: Connection refused: Redis server not responding
[2024-12-28 08:30:01] ERROR: Retrying Redis connection in 5 seconds...
[2024-12-28 08:30:06] INFO: Redis connection recovered
[2024-12-28 09:15:00] WARNING: High memory usage detected (85%)
[2024-12-28 09:15:01] INFO: Running garbage collection
[2024-12-28 09:15:05] INFO: Memory usage reduced to 62%
[2024-12-28 09:30:00] ERROR: Worker node disconnected: remote-worker-2
[2024-12-28 09:30:01] WARNING: Reassigning 3 tasks from remote-worker-2
[2024-12-28 09:30:05] INFO: Tasks reassigned successfully`
export function getMockLogFiles(): LogFilesResponse {
return {
files: mockLogFiles,
}
}
export function getMockSystemLogs(params?: {
file?: string
lines?: number
}): SystemLogResponse {
const filename = params?.file || 'xingrin.log'
const lines = params?.lines || 100
let content: string
if (filename.includes('error')) {
content = mockErrorLogContent
} else {
content = mockSystemLogContent
}
// 模拟行数限制
const contentLines = content.split('\n')
const limitedContent = contentLines.slice(-lines).join('\n')
return {
content: limitedContent,
}
}

149
frontend/mock/data/tools.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { Tool, GetToolsResponse } from '@/types/tool.types'
export const mockTools: Tool[] = [
{
id: 1,
name: 'subfinder',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/subfinder',
version: 'v2.6.3',
description: 'Fast passive subdomain enumeration tool.',
categoryNames: ['subdomain', 'recon'],
directory: '/opt/tools/subfinder',
installCommand: 'go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest',
updateCommand: 'go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest',
versionCommand: 'subfinder -version',
createdAt: '2024-12-20T10:00:00Z',
updatedAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
name: 'httpx',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/httpx',
version: 'v1.6.0',
description: 'Fast and multi-purpose HTTP toolkit.',
categoryNames: ['http', 'recon'],
directory: '/opt/tools/httpx',
installCommand: 'go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest',
updateCommand: 'go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest',
versionCommand: 'httpx -version',
createdAt: '2024-12-20T10:01:00Z',
updatedAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
name: 'nuclei',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/nuclei',
version: 'v3.1.0',
description: 'Fast and customizable vulnerability scanner.',
categoryNames: ['vulnerability'],
directory: '/opt/tools/nuclei',
installCommand: 'go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest',
updateCommand: 'go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest',
versionCommand: 'nuclei -version',
createdAt: '2024-12-20T10:02:00Z',
updatedAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
name: 'naabu',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/naabu',
version: 'v2.2.1',
description: 'Fast port scanner written in go.',
categoryNames: ['port', 'network'],
directory: '/opt/tools/naabu',
installCommand: 'go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest',
updateCommand: 'go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest',
versionCommand: 'naabu -version',
createdAt: '2024-12-20T10:03:00Z',
updatedAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
name: 'katana',
type: 'opensource',
repoUrl: 'https://github.com/projectdiscovery/katana',
version: 'v1.0.4',
description: 'Next-generation crawling and spidering framework.',
categoryNames: ['crawler', 'recon'],
directory: '/opt/tools/katana',
installCommand: 'go install github.com/projectdiscovery/katana/cmd/katana@latest',
updateCommand: 'go install github.com/projectdiscovery/katana/cmd/katana@latest',
versionCommand: 'katana -version',
createdAt: '2024-12-20T10:04:00Z',
updatedAt: '2024-12-28T10:04:00Z',
},
{
id: 6,
name: 'ffuf',
type: 'opensource',
repoUrl: 'https://github.com/ffuf/ffuf',
version: 'v2.1.0',
description: 'Fast web fuzzer written in Go.',
categoryNames: ['directory', 'fuzzer'],
directory: '/opt/tools/ffuf',
installCommand: 'go install github.com/ffuf/ffuf/v2@latest',
updateCommand: 'go install github.com/ffuf/ffuf/v2@latest',
versionCommand: 'ffuf -V',
createdAt: '2024-12-20T10:05:00Z',
updatedAt: '2024-12-28T10:05:00Z',
},
{
id: 7,
name: 'amass',
type: 'opensource',
repoUrl: 'https://github.com/owasp-amass/amass',
version: 'v4.2.0',
description: 'In-depth attack surface mapping and asset discovery.',
categoryNames: ['subdomain', 'recon'],
directory: '/opt/tools/amass',
installCommand: 'go install -v github.com/owasp-amass/amass/v4/...@master',
updateCommand: 'go install -v github.com/owasp-amass/amass/v4/...@master',
versionCommand: 'amass -version',
createdAt: '2024-12-20T10:06:00Z',
updatedAt: '2024-12-28T10:06:00Z',
},
{
id: 8,
name: 'xingfinger',
type: 'custom',
repoUrl: '',
version: '1.0.0',
description: '自定义指纹识别工具',
categoryNames: ['recon'],
directory: '/opt/tools/xingfinger',
installCommand: '',
updateCommand: '',
versionCommand: '',
createdAt: '2024-12-20T10:07:00Z',
updatedAt: '2024-12-28T10:07:00Z',
},
]
export function getMockTools(params?: {
page?: number
pageSize?: number
}): GetToolsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const total = mockTools.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const tools = mockTools.slice(start, start + pageSize)
return {
tools,
total,
page,
pageSize,
totalPages,
}
}
export function getMockToolById(id: number): Tool | undefined {
return mockTools.find(t => t.id === id)
}

View File

@@ -0,0 +1,119 @@
import type { Wordlist, GetWordlistsResponse } from '@/types/wordlist.types'
export const mockWordlists: Wordlist[] = [
{
id: 1,
name: 'common-dirs.txt',
description: '常用目录字典',
fileSize: 45678,
lineCount: 4567,
fileHash: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6',
createdAt: '2024-12-20T10:00:00Z',
updatedAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
name: 'subdomains-top1million.txt',
description: 'Top 100万子域名字典',
fileSize: 12345678,
lineCount: 1000000,
fileHash: 'b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7',
createdAt: '2024-12-20T10:01:00Z',
updatedAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
name: 'api-endpoints.txt',
description: 'API 端点字典',
fileSize: 23456,
lineCount: 2345,
fileHash: 'c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8',
createdAt: '2024-12-20T10:02:00Z',
updatedAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
name: 'params.txt',
description: '常用参数名字典',
fileSize: 8901,
lineCount: 890,
fileHash: 'd4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9',
createdAt: '2024-12-20T10:03:00Z',
updatedAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
name: 'sensitive-files.txt',
description: '敏感文件字典',
fileSize: 5678,
lineCount: 567,
fileHash: 'e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0',
createdAt: '2024-12-20T10:04:00Z',
updatedAt: '2024-12-28T10:04:00Z',
},
{
id: 6,
name: 'raft-large-directories.txt',
description: 'RAFT 大型目录字典',
fileSize: 987654,
lineCount: 98765,
fileHash: 'f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1',
createdAt: '2024-12-20T10:05:00Z',
updatedAt: '2024-12-28T10:05:00Z',
},
]
export const mockWordlistContent = `admin
api
backup
config
dashboard
debug
dev
docs
download
files
images
js
login
logs
manager
private
public
static
test
upload
users
v1
v2
wp-admin
wp-content`
export function getMockWordlists(params?: {
page?: number
pageSize?: number
}): GetWordlistsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const total = mockWordlists.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const results = mockWordlists.slice(start, start + pageSize)
return {
results,
total,
page,
pageSize,
totalPages,
}
}
export function getMockWordlistById(id: number): Wordlist | undefined {
return mockWordlists.find(w => w.id === id)
}
export function getMockWordlistContent(): string {
return mockWordlistContent
}

View File

@@ -105,3 +105,80 @@ export {
getMockScheduledScans,
getMockScheduledScanById,
} from './data/scheduled-scans'
// Directories
export {
mockDirectories,
getMockDirectories,
getMockDirectoryById,
} from './data/directories'
// Fingerprints
export {
mockEholeFingerprints,
mockGobyFingerprints,
mockWappalyzerFingerprints,
mockFingersFingerprints,
mockFingerPrintHubFingerprints,
mockARLFingerprints,
mockFingerprintStats,
getMockEholeFingerprints,
getMockGobyFingerprints,
getMockWappalyzerFingerprints,
getMockFingersFingerprints,
getMockFingerPrintHubFingerprints,
getMockARLFingerprints,
getMockFingerprintStats,
} from './data/fingerprints'
// IP Addresses
export {
mockIPAddresses,
getMockIPAddresses,
getMockIPAddressByIP,
} from './data/ip-addresses'
// Search
export {
getMockSearchResults,
} from './data/search'
// Tools
export {
mockTools,
getMockTools,
getMockToolById,
} from './data/tools'
// Wordlists
export {
mockWordlists,
mockWordlistContent,
getMockWordlists,
getMockWordlistById,
getMockWordlistContent,
} from './data/wordlists'
// Nuclei Templates
export {
mockNucleiTemplateTree,
mockNucleiTemplateContent,
getMockNucleiTemplateTree,
getMockNucleiTemplateContent,
} from './data/nuclei-templates'
// System Logs
export {
mockLogFiles,
mockSystemLogContent,
mockErrorLogContent,
getMockLogFiles,
getMockSystemLogs,
} from './data/system-logs'
// Notification Settings
export {
mockNotificationSettings,
getMockNotificationSettings,
updateMockNotificationSettings,
} from './data/notification-settings'

View File

@@ -0,0 +1,14 @@
import { api } from '@/lib/api-client'
import type { ApiKeySettings } from '@/types/api-key-settings.types'
export class ApiKeySettingsService {
static async getSettings(): Promise<ApiKeySettings> {
const res = await api.get<ApiKeySettings>('/settings/api-keys/')
return res.data
}
static async updateSettings(data: Partial<ApiKeySettings>): Promise<ApiKeySettings> {
const res = await api.put<ApiKeySettings>('/settings/api-keys/', data)
return res.data
}
}

View File

@@ -0,0 +1,42 @@
/**
* API Key 配置类型定义
* 用于 subfinder 第三方数据源配置
*/
// 单字段 Provider 配置hunter, shodan, zoomeye, securitytrails, threatbook, quake
export interface SingleFieldProviderConfig {
enabled: boolean
apiKey: string
}
// FOFA Provider 配置email + apiKey
export interface FofaProviderConfig {
enabled: boolean
email: string
apiKey: string
}
// Censys Provider 配置apiId + apiSecret
export interface CensysProviderConfig {
enabled: boolean
apiId: string
apiSecret: string
}
// 完整的 API Key 配置
export interface ApiKeySettings {
fofa: FofaProviderConfig
hunter: SingleFieldProviderConfig
shodan: SingleFieldProviderConfig
censys: CensysProviderConfig
zoomeye: SingleFieldProviderConfig
securitytrails: SingleFieldProviderConfig
threatbook: SingleFieldProviderConfig
quake: SingleFieldProviderConfig
}
// Provider 类型
export type ProviderKey = keyof ApiKeySettings
// Provider 配置联合类型
export type ProviderConfig = FofaProviderConfig | CensysProviderConfig | SingleFieldProviderConfig