Compare commits

..

7 Commits

Author SHA1 Message Date
yyhuni
77a6f45909 fix:搜索的楼栋统计问题 2026-01-02 23:12:55 +08:00
yyhuni
49d1f1f1bb 采用ivm增量更新方案进行搜索 2026-01-02 22:46:40 +08:00
yyhuni
db8ecb1644 feat(search): add mock data infrastructure and vulnerability detail integration
- Add comprehensive mock data configuration for all major entities (dashboard, endpoints, organizations, scans, subdomains, targets, vulnerabilities, websites)
- Implement mock service layer with centralized config for development and testing
- Add vulnerability detail dialog integration to search results with lazy loading
- Enhance search result card with vulnerability viewing capability
- Update search materialized view migration to include vulnerability name field
- Implement default host fuzzy search fallback for bare text queries without operators
- Add vulnerability data formatting in search view for consistent API response structure
- Configure Vercel deployment settings and update Next.js configuration
- Update all service layers to support mock data injection for development environment
- Extend search types with improved vulnerability data structure
- Add internationalization strings for vulnerability loading errors
- Enable rapid frontend development and testing without backend API dependency
2026-01-02 19:06:09 +08:00
yyhuni
18cc016268 feat(search): implement advanced query parser with expression syntax support
- Add SearchQueryParser class to parse complex search expressions with operators (=, ==, !=)
- Support logical operators && (AND) and || (OR) for combining multiple conditions
- Implement field mapping for frontend to database field translation
- Add support for array field searching (tech stack) with unnest and ANY operators
- Support fuzzy matching (=), exact matching (==), and negation (!=) operators
- Add proper SQL injection prevention through parameterized queries
- Refactor search service to use expression-based filtering instead of simple filters
- Update search views to integrate new query parser
- Enhance frontend search hook and service to support new expression syntax
- Update search types to reflect new query structure
- Improve search page UI to display expression syntax examples and help text
- Enable complex multi-condition searches like: host="api" && tech="nginx" || status=="200"
2026-01-02 17:46:31 +08:00
yyhuni
23bc463283 feat(search): improve technology stack filtering with fuzzy matching
- Replace exact array matching with fuzzy search using ILIKE operator
- Update tech filter to search within array elements using unnest() and EXISTS
- Support partial technology name matching (e.g., "node" matches "nodejs")
- Apply consistent fuzzy matching logic across both search methods
- Enhance user experience by allowing flexible technology stack queries
2026-01-02 17:01:24 +08:00
yyhuni
7b903b91b2 feat(search): implement comprehensive search infrastructure with materialized views and pagination
- Add asset search service with materialized view support for optimized queries
- Implement search refresh service for maintaining up-to-date search indexes
- Create database migrations for AssetStatistics, StatisticsHistory, Directory, and DirectorySnapshot models
- Add PostgreSQL GIN indexes with trigram operators for full-text search capabilities
- Implement search pagination component with configurable page size and navigation
- Add search result card component with enhanced asset display formatting
- Create search API views with filtering and sorting capabilities
- Add use-search hook for client-side search state management
- Implement search service client for API communication
- Update search types with pagination metadata and result structures
- Add English and Chinese translations for search UI components
- Enhance scheduler to support search index refresh tasks
- Refactor asset views into modular search_views and asset_views
- Update URL routing to support new search endpoints
- Improve scan flow handlers for better search index integration
2026-01-02 16:57:54 +08:00
yyhuni
b3136d51b9 搜索页面前端UI设计完成 2026-01-02 10:07:26 +08:00
54 changed files with 4635 additions and 72 deletions

View File

@@ -1,4 +1,5 @@
import logging
import sys
from django.apps import AppConfig
@@ -16,6 +17,9 @@ class AssetConfig(AppConfig):
# 启用 pg_trgm 扩展(用于文本模糊搜索索引)
# 用于已有数据库升级场景
self._ensure_pg_trgm_extension()
# 验证 pg_ivm 扩展是否可用(用于 IMMV 增量维护)
self._verify_pg_ivm_extension()
def _ensure_pg_trgm_extension(self):
"""
@@ -43,3 +47,60 @@ class AssetConfig(AppConfig):
"请手动执行: CREATE EXTENSION IF NOT EXISTS pg_trgm;",
str(e)
)
def _verify_pg_ivm_extension(self):
"""
验证 pg_ivm 扩展是否可用。
pg_ivm 用于 IMMV增量维护物化视图是系统必需的扩展。
如果不可用,将记录错误并退出。
"""
from django.db import connection
# 检查是否为 PostgreSQL 数据库
if connection.vendor != 'postgresql':
logger.debug("跳过 pg_ivm 验证:当前数据库不是 PostgreSQL")
return
# 跳过某些管理命令(如 migrate、makemigrations
import sys
if len(sys.argv) > 1 and sys.argv[1] in ('migrate', 'makemigrations', 'collectstatic', 'check'):
logger.debug("跳过 pg_ivm 验证:当前为管理命令")
return
try:
with connection.cursor() as cursor:
# 检查 pg_ivm 扩展是否已安装
cursor.execute("""
SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_ivm'
""")
count = cursor.fetchone()[0]
if count > 0:
logger.info("✓ pg_ivm 扩展已启用")
else:
# 尝试创建扩展
try:
cursor.execute("CREATE EXTENSION IF NOT EXISTS pg_ivm;")
logger.info("✓ pg_ivm 扩展已创建并启用")
except Exception as create_error:
logger.error(
"=" * 60 + "\n"
"错误: pg_ivm 扩展未安装\n"
"=" * 60 + "\n"
"pg_ivm 是系统必需的扩展,用于增量维护物化视图。\n\n"
"请在 PostgreSQL 服务器上安装 pg_ivm\n"
" curl -sSL https://raw.githubusercontent.com/yyhuni/xingrin/main/docker/scripts/install-pg-ivm.sh | sudo bash\n\n"
"或手动安装:\n"
" 1. apt install build-essential postgresql-server-dev-15 git\n"
" 2. git clone https://github.com/sraoss/pg_ivm.git && cd pg_ivm && make && make install\n"
" 3. 在 postgresql.conf 中添加: shared_preload_libraries = 'pg_ivm'\n"
" 4. 重启 PostgreSQL\n"
"=" * 60
)
# 在生产环境中退出,开发环境中仅警告
from django.conf import settings
if not settings.DEBUG:
sys.exit(1)
except Exception as e:
logger.error(f"pg_ivm 扩展验证失败: {e}")

View File

@@ -0,0 +1,345 @@
# Generated by Django 5.2.7 on 2026-01-02 04:45
import django.contrib.postgres.fields
import django.contrib.postgres.indexes
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('scan', '0001_initial'),
('targets', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AssetStatistics',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('total_targets', models.IntegerField(default=0, help_text='目标总数')),
('total_subdomains', models.IntegerField(default=0, help_text='子域名总数')),
('total_ips', models.IntegerField(default=0, help_text='IP地址总数')),
('total_endpoints', models.IntegerField(default=0, help_text='端点总数')),
('total_websites', models.IntegerField(default=0, help_text='网站总数')),
('total_vulns', models.IntegerField(default=0, help_text='漏洞总数')),
('total_assets', models.IntegerField(default=0, help_text='总资产数(子域名+IP+端点+网站)')),
('prev_targets', models.IntegerField(default=0, help_text='上次目标总数')),
('prev_subdomains', models.IntegerField(default=0, help_text='上次子域名总数')),
('prev_ips', models.IntegerField(default=0, help_text='上次IP地址总数')),
('prev_endpoints', models.IntegerField(default=0, help_text='上次端点总数')),
('prev_websites', models.IntegerField(default=0, help_text='上次网站总数')),
('prev_vulns', models.IntegerField(default=0, help_text='上次漏洞总数')),
('prev_assets', models.IntegerField(default=0, help_text='上次总资产数')),
('updated_at', models.DateTimeField(auto_now=True, help_text='最后更新时间')),
],
options={
'verbose_name': '资产统计',
'verbose_name_plural': '资产统计',
'db_table': 'asset_statistics',
},
),
migrations.CreateModel(
name='StatisticsHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(help_text='统计日期', unique=True)),
('total_targets', models.IntegerField(default=0, help_text='目标总数')),
('total_subdomains', models.IntegerField(default=0, help_text='子域名总数')),
('total_ips', models.IntegerField(default=0, help_text='IP地址总数')),
('total_endpoints', models.IntegerField(default=0, help_text='端点总数')),
('total_websites', models.IntegerField(default=0, help_text='网站总数')),
('total_vulns', models.IntegerField(default=0, help_text='漏洞总数')),
('total_assets', models.IntegerField(default=0, help_text='总资产数')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
],
options={
'verbose_name': '统计历史',
'verbose_name_plural': '统计历史',
'db_table': 'statistics_history',
'ordering': ['-date'],
'indexes': [models.Index(fields=['date'], name='statistics__date_1d29cd_idx')],
},
),
migrations.CreateModel(
name='Directory',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.CharField(help_text='完整请求 URL', max_length=2000)),
('status', models.IntegerField(blank=True, help_text='HTTP 响应状态码', null=True)),
('content_length', models.BigIntegerField(blank=True, help_text='响应体字节大小Content-Length 或实际长度)', null=True)),
('words', models.IntegerField(blank=True, help_text='响应体中单词数量(按空格分割)', null=True)),
('lines', models.IntegerField(blank=True, help_text='响应体行数(按换行符分割)', null=True)),
('content_type', models.CharField(blank=True, default='', help_text='响应头 Content-Type 值', max_length=200)),
('duration', models.BigIntegerField(blank=True, help_text='请求耗时(单位:纳秒)', null=True)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('target', models.ForeignKey(help_text='所属的扫描目标', on_delete=django.db.models.deletion.CASCADE, related_name='directories', to='targets.target')),
],
options={
'verbose_name': '目录',
'verbose_name_plural': '目录',
'db_table': 'directory',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='directory_created_2cef03_idx'), models.Index(fields=['target'], name='directory_target__e310c8_idx'), models.Index(fields=['url'], name='directory_url_ba40cd_idx'), models.Index(fields=['status'], name='directory_status_40bbe6_idx'), django.contrib.postgres.indexes.GinIndex(fields=['url'], name='directory_url_trgm_idx', opclasses=['gin_trgm_ops'])],
'constraints': [models.UniqueConstraint(fields=('target', 'url'), name='unique_directory_url_target')],
},
),
migrations.CreateModel(
name='DirectorySnapshot',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.CharField(help_text='目录URL', max_length=2000)),
('status', models.IntegerField(blank=True, help_text='HTTP状态码', null=True)),
('content_length', models.BigIntegerField(blank=True, help_text='内容长度', null=True)),
('words', models.IntegerField(blank=True, help_text='响应体中单词数量(按空格分割)', null=True)),
('lines', models.IntegerField(blank=True, help_text='响应体行数(按换行符分割)', null=True)),
('content_type', models.CharField(blank=True, default='', help_text='响应头 Content-Type 值', max_length=200)),
('duration', models.BigIntegerField(blank=True, help_text='请求耗时(单位:纳秒)', null=True)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('scan', models.ForeignKey(help_text='所属的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='directory_snapshots', to='scan.scan')),
],
options={
'verbose_name': '目录快照',
'verbose_name_plural': '目录快照',
'db_table': 'directory_snapshot',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['scan'], name='directory_s_scan_id_c45900_idx'), models.Index(fields=['url'], name='directory_s_url_b4b72b_idx'), models.Index(fields=['status'], name='directory_s_status_e9f57e_idx'), models.Index(fields=['content_type'], name='directory_s_content_45e864_idx'), models.Index(fields=['-created_at'], name='directory_s_created_eb9d27_idx'), django.contrib.postgres.indexes.GinIndex(fields=['url'], name='dir_snap_url_trgm', opclasses=['gin_trgm_ops'])],
'constraints': [models.UniqueConstraint(fields=('scan', 'url'), name='unique_directory_per_scan_snapshot')],
},
),
migrations.CreateModel(
name='Endpoint',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.CharField(help_text='最终访问的完整URL', max_length=2000)),
('host', models.CharField(blank=True, default='', help_text='主机名域名或IP地址', max_length=253)),
('location', models.CharField(blank=True, default='', help_text='重定向地址HTTP 3xx 响应头 Location', max_length=1000)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('title', models.CharField(blank=True, default='', help_text='网页标题HTML <title> 标签内容)', max_length=1000)),
('webserver', models.CharField(blank=True, default='', help_text='服务器类型HTTP 响应头 Server 值)', max_length=200)),
('response_body', models.TextField(blank=True, default='', help_text='HTTP响应体')),
('content_type', models.CharField(blank=True, default='', help_text='响应类型HTTP Content-Type 响应头)', max_length=200)),
('tech', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, default=list, help_text='技术栈(服务器/框架/语言等)', size=None)),
('status_code', models.IntegerField(blank=True, help_text='HTTP状态码', null=True)),
('content_length', models.IntegerField(blank=True, help_text='响应体大小(单位字节)', null=True)),
('vhost', models.BooleanField(blank=True, help_text='是否支持虚拟主机', null=True)),
('matched_gf_patterns', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, default=list, help_text='匹配的GF模式列表用于识别敏感端点如api, debug, config等', size=None)),
('response_headers', models.TextField(blank=True, default='', help_text='原始HTTP响应头')),
('target', models.ForeignKey(help_text='所属的扫描目标(主关联字段,表示所属关系,不能为空)', on_delete=django.db.models.deletion.CASCADE, related_name='endpoints', to='targets.target')),
],
options={
'verbose_name': '端点',
'verbose_name_plural': '端点',
'db_table': 'endpoint',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='endpoint_created_44fe9c_idx'), models.Index(fields=['target'], name='endpoint_target__7f9065_idx'), models.Index(fields=['url'], name='endpoint_url_30f66e_idx'), models.Index(fields=['host'], name='endpoint_host_5b4cc8_idx'), models.Index(fields=['status_code'], name='endpoint_status__5d4fdd_idx'), models.Index(fields=['title'], name='endpoint_title_29e26c_idx'), django.contrib.postgres.indexes.GinIndex(fields=['tech'], name='endpoint_tech_2bfa7c_gin'), django.contrib.postgres.indexes.GinIndex(fields=['response_headers'], name='endpoint_resp_headers_trgm_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['url'], name='endpoint_url_trgm_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['title'], name='endpoint_title_trgm_idx', opclasses=['gin_trgm_ops'])],
'constraints': [models.UniqueConstraint(fields=('url', 'target'), name='unique_endpoint_url_target')],
},
),
migrations.CreateModel(
name='EndpointSnapshot',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.CharField(help_text='端点URL', max_length=2000)),
('host', models.CharField(blank=True, default='', help_text='主机名域名或IP地址', max_length=253)),
('title', models.CharField(blank=True, default='', help_text='页面标题', max_length=1000)),
('status_code', models.IntegerField(blank=True, help_text='HTTP状态码', null=True)),
('content_length', models.IntegerField(blank=True, help_text='内容长度', null=True)),
('location', models.CharField(blank=True, default='', help_text='重定向位置', max_length=1000)),
('webserver', models.CharField(blank=True, default='', help_text='Web服务器', max_length=200)),
('content_type', models.CharField(blank=True, default='', help_text='内容类型', max_length=200)),
('tech', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, default=list, help_text='技术栈', size=None)),
('response_body', models.TextField(blank=True, default='', help_text='HTTP响应体')),
('vhost', models.BooleanField(blank=True, help_text='虚拟主机标志', null=True)),
('matched_gf_patterns', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, default=list, help_text='匹配的GF模式列表', size=None)),
('response_headers', models.TextField(blank=True, default='', help_text='原始HTTP响应头')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('scan', models.ForeignKey(help_text='所属的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='endpoint_snapshots', to='scan.scan')),
],
options={
'verbose_name': '端点快照',
'verbose_name_plural': '端点快照',
'db_table': 'endpoint_snapshot',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['scan'], name='endpoint_sn_scan_id_6ac9a7_idx'), models.Index(fields=['url'], name='endpoint_sn_url_205160_idx'), models.Index(fields=['host'], name='endpoint_sn_host_577bfd_idx'), models.Index(fields=['title'], name='endpoint_sn_title_516a05_idx'), models.Index(fields=['status_code'], name='endpoint_sn_status__83efb0_idx'), models.Index(fields=['webserver'], name='endpoint_sn_webserv_66be83_idx'), models.Index(fields=['-created_at'], name='endpoint_sn_created_21fb5b_idx'), django.contrib.postgres.indexes.GinIndex(fields=['tech'], name='endpoint_sn_tech_0d0752_gin'), django.contrib.postgres.indexes.GinIndex(fields=['response_headers'], name='ep_snap_resp_hdr_trgm', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['url'], name='ep_snap_url_trgm', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['title'], name='ep_snap_title_trgm', opclasses=['gin_trgm_ops'])],
'constraints': [models.UniqueConstraint(fields=('scan', 'url'), name='unique_endpoint_per_scan_snapshot')],
},
),
migrations.CreateModel(
name='HostPortMapping',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('host', models.CharField(help_text='主机名域名或IP', max_length=1000)),
('ip', models.GenericIPAddressField(help_text='IP地址')),
('port', models.IntegerField(help_text='端口号1-65535', validators=[django.core.validators.MinValueValidator(1, message='端口号必须大于等于1'), django.core.validators.MaxValueValidator(65535, message='端口号必须小于等于65535')])),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('target', models.ForeignKey(help_text='所属的扫描目标', on_delete=django.db.models.deletion.CASCADE, related_name='host_port_mappings', to='targets.target')),
],
options={
'verbose_name': '主机端口映射',
'verbose_name_plural': '主机端口映射',
'db_table': 'host_port_mapping',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['target'], name='host_port_m_target__943e9b_idx'), models.Index(fields=['host'], name='host_port_m_host_f78363_idx'), models.Index(fields=['ip'], name='host_port_m_ip_2e6f02_idx'), models.Index(fields=['port'], name='host_port_m_port_9fb9ff_idx'), models.Index(fields=['host', 'ip'], name='host_port_m_host_3ce245_idx'), models.Index(fields=['-created_at'], name='host_port_m_created_11cd22_idx')],
'constraints': [models.UniqueConstraint(fields=('target', 'host', 'ip', 'port'), name='unique_target_host_ip_port')],
},
),
migrations.CreateModel(
name='HostPortMappingSnapshot',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('host', models.CharField(help_text='主机名域名或IP', max_length=1000)),
('ip', models.GenericIPAddressField(help_text='IP地址')),
('port', models.IntegerField(help_text='端口号1-65535', validators=[django.core.validators.MinValueValidator(1, message='端口号必须大于等于1'), django.core.validators.MaxValueValidator(65535, message='端口号必须小于等于65535')])),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('scan', models.ForeignKey(help_text='所属的扫描任务(主关联)', on_delete=django.db.models.deletion.CASCADE, related_name='host_port_mapping_snapshots', to='scan.scan')),
],
options={
'verbose_name': '主机端口映射快照',
'verbose_name_plural': '主机端口映射快照',
'db_table': 'host_port_mapping_snapshot',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['scan'], name='host_port_m_scan_id_50ba0b_idx'), models.Index(fields=['host'], name='host_port_m_host_e99054_idx'), models.Index(fields=['ip'], name='host_port_m_ip_54818c_idx'), models.Index(fields=['port'], name='host_port_m_port_ed7b48_idx'), models.Index(fields=['host', 'ip'], name='host_port_m_host_8a463a_idx'), models.Index(fields=['scan', 'host'], name='host_port_m_scan_id_426fdb_idx'), models.Index(fields=['-created_at'], name='host_port_m_created_fb28b8_idx')],
'constraints': [models.UniqueConstraint(fields=('scan', 'host', 'ip', 'port'), name='unique_scan_host_ip_port_snapshot')],
},
),
migrations.CreateModel(
name='Subdomain',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(help_text='子域名名称', max_length=1000)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('target', models.ForeignKey(help_text='所属的扫描目标(主关联字段,表示所属关系,不能为空)', on_delete=django.db.models.deletion.CASCADE, related_name='subdomains', to='targets.target')),
],
options={
'verbose_name': '子域名',
'verbose_name_plural': '子域名',
'db_table': 'subdomain',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='subdomain_created_e187a8_idx'), models.Index(fields=['name', 'target'], name='subdomain_name_60e1d0_idx'), models.Index(fields=['target'], name='subdomain_target__e409f0_idx'), models.Index(fields=['name'], name='subdomain_name_d40ba7_idx'), django.contrib.postgres.indexes.GinIndex(fields=['name'], name='subdomain_name_trgm_idx', opclasses=['gin_trgm_ops'])],
'constraints': [models.UniqueConstraint(fields=('name', 'target'), name='unique_subdomain_name_target')],
},
),
migrations.CreateModel(
name='SubdomainSnapshot',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(help_text='子域名名称', max_length=1000)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('scan', models.ForeignKey(help_text='所属的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='subdomain_snapshots', to='scan.scan')),
],
options={
'verbose_name': '子域名快照',
'verbose_name_plural': '子域名快照',
'db_table': 'subdomain_snapshot',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['scan'], name='subdomain_s_scan_id_68c253_idx'), models.Index(fields=['name'], name='subdomain_s_name_2da42b_idx'), models.Index(fields=['-created_at'], name='subdomain_s_created_d2b48e_idx'), django.contrib.postgres.indexes.GinIndex(fields=['name'], name='subdomain_snap_name_trgm', opclasses=['gin_trgm_ops'])],
'constraints': [models.UniqueConstraint(fields=('scan', 'name'), name='unique_subdomain_per_scan_snapshot')],
},
),
migrations.CreateModel(
name='Vulnerability',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.CharField(help_text='漏洞所在的URL', max_length=2000)),
('vuln_type', models.CharField(help_text='漏洞类型(如 xss, sqli', max_length=100)),
('severity', models.CharField(choices=[('unknown', '未知'), ('info', '信息'), ('low', ''), ('medium', ''), ('high', ''), ('critical', '危急')], default='unknown', help_text='严重性(未知/信息/低/中/高/危急)', max_length=20)),
('source', models.CharField(blank=True, default='', help_text='来源工具(如 dalfox, nuclei, crlfuzz', max_length=50)),
('cvss_score', models.DecimalField(blank=True, decimal_places=1, help_text='CVSS 评分0.0-10.0', max_digits=3, null=True)),
('description', models.TextField(blank=True, default='', help_text='漏洞描述')),
('raw_output', models.JSONField(blank=True, default=dict, help_text='工具原始输出')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('target', models.ForeignKey(help_text='所属的扫描目标', on_delete=django.db.models.deletion.CASCADE, related_name='vulnerabilities', to='targets.target')),
],
options={
'verbose_name': '漏洞',
'verbose_name_plural': '漏洞',
'db_table': 'vulnerability',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['target'], name='vulnerabili_target__755a02_idx'), models.Index(fields=['vuln_type'], name='vulnerabili_vuln_ty_3010cd_idx'), models.Index(fields=['severity'], name='vulnerabili_severit_1a798b_idx'), models.Index(fields=['source'], name='vulnerabili_source_7c7552_idx'), models.Index(fields=['url'], name='vulnerabili_url_4dcc4d_idx'), models.Index(fields=['-created_at'], name='vulnerabili_created_e25ff7_idx')],
},
),
migrations.CreateModel(
name='VulnerabilitySnapshot',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.CharField(help_text='漏洞所在的URL', max_length=2000)),
('vuln_type', models.CharField(help_text='漏洞类型(如 xss, sqli', max_length=100)),
('severity', models.CharField(choices=[('unknown', '未知'), ('info', '信息'), ('low', ''), ('medium', ''), ('high', ''), ('critical', '危急')], default='unknown', help_text='严重性(未知/信息/低/中/高/危急)', max_length=20)),
('source', models.CharField(blank=True, default='', help_text='来源工具(如 dalfox, nuclei, crlfuzz', max_length=50)),
('cvss_score', models.DecimalField(blank=True, decimal_places=1, help_text='CVSS 评分0.0-10.0', max_digits=3, null=True)),
('description', models.TextField(blank=True, default='', help_text='漏洞描述')),
('raw_output', models.JSONField(blank=True, default=dict, help_text='工具原始输出')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('scan', models.ForeignKey(help_text='所属的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='vulnerability_snapshots', to='scan.scan')),
],
options={
'verbose_name': '漏洞快照',
'verbose_name_plural': '漏洞快照',
'db_table': 'vulnerability_snapshot',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['scan'], name='vulnerabili_scan_id_7b81c9_idx'), models.Index(fields=['url'], name='vulnerabili_url_11a707_idx'), models.Index(fields=['vuln_type'], name='vulnerabili_vuln_ty_6b90ee_idx'), models.Index(fields=['severity'], name='vulnerabili_severit_4eae0d_idx'), models.Index(fields=['source'], name='vulnerabili_source_968b1f_idx'), models.Index(fields=['-created_at'], name='vulnerabili_created_53a12e_idx')],
},
),
migrations.CreateModel(
name='WebSite',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.CharField(help_text='最终访问的完整URL', max_length=2000)),
('host', models.CharField(blank=True, default='', help_text='主机名域名或IP地址', max_length=253)),
('location', models.CharField(blank=True, default='', help_text='重定向地址HTTP 3xx 响应头 Location', max_length=1000)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('title', models.CharField(blank=True, default='', help_text='网页标题HTML <title> 标签内容)', max_length=1000)),
('webserver', models.CharField(blank=True, default='', help_text='服务器类型HTTP 响应头 Server 值)', max_length=200)),
('response_body', models.TextField(blank=True, default='', help_text='HTTP响应体')),
('content_type', models.CharField(blank=True, default='', help_text='响应类型HTTP Content-Type 响应头)', max_length=200)),
('tech', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, default=list, help_text='技术栈(服务器/框架/语言等)', size=None)),
('status_code', models.IntegerField(blank=True, help_text='HTTP状态码', null=True)),
('content_length', models.IntegerField(blank=True, help_text='响应体大小(单位字节)', null=True)),
('vhost', models.BooleanField(blank=True, help_text='是否支持虚拟主机', null=True)),
('response_headers', models.TextField(blank=True, default='', help_text='原始HTTP响应头')),
('target', models.ForeignKey(help_text='所属的扫描目标(主关联字段,表示所属关系,不能为空)', on_delete=django.db.models.deletion.CASCADE, related_name='websites', to='targets.target')),
],
options={
'verbose_name': '站点',
'verbose_name_plural': '站点',
'db_table': 'website',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='website_created_c9cfd2_idx'), models.Index(fields=['url'], name='website_url_b18883_idx'), models.Index(fields=['host'], name='website_host_996b50_idx'), models.Index(fields=['target'], name='website_target__2a353b_idx'), models.Index(fields=['title'], name='website_title_c2775b_idx'), models.Index(fields=['status_code'], name='website_status__51663d_idx'), django.contrib.postgres.indexes.GinIndex(fields=['tech'], name='website_tech_e3f0cb_gin'), django.contrib.postgres.indexes.GinIndex(fields=['response_headers'], name='website_resp_headers_trgm_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['url'], name='website_url_trgm_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['title'], name='website_title_trgm_idx', opclasses=['gin_trgm_ops'])],
'constraints': [models.UniqueConstraint(fields=('url', 'target'), name='unique_website_url_target')],
},
),
migrations.CreateModel(
name='WebsiteSnapshot',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.CharField(help_text='站点URL', max_length=2000)),
('host', models.CharField(blank=True, default='', help_text='主机名域名或IP地址', max_length=253)),
('title', models.CharField(blank=True, default='', help_text='页面标题', max_length=500)),
('status', models.IntegerField(blank=True, help_text='HTTP状态码', null=True)),
('content_length', models.BigIntegerField(blank=True, help_text='内容长度', null=True)),
('location', models.CharField(blank=True, default='', help_text='重定向位置', max_length=1000)),
('web_server', models.CharField(blank=True, default='', help_text='Web服务器', max_length=200)),
('content_type', models.CharField(blank=True, default='', help_text='内容类型', max_length=200)),
('tech', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, default=list, help_text='技术栈', size=None)),
('response_body', models.TextField(blank=True, default='', help_text='HTTP响应体')),
('vhost', models.BooleanField(blank=True, help_text='虚拟主机标志', null=True)),
('response_headers', models.TextField(blank=True, default='', help_text='原始HTTP响应头')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('scan', models.ForeignKey(help_text='所属的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='website_snapshots', to='scan.scan')),
],
options={
'verbose_name': '网站快照',
'verbose_name_plural': '网站快照',
'db_table': 'website_snapshot',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['scan'], name='website_sna_scan_id_26b6dc_idx'), models.Index(fields=['url'], name='website_sna_url_801a70_idx'), models.Index(fields=['host'], name='website_sna_host_348fe1_idx'), models.Index(fields=['title'], name='website_sna_title_b1a5ee_idx'), models.Index(fields=['-created_at'], name='website_sna_created_2c149a_idx'), django.contrib.postgres.indexes.GinIndex(fields=['tech'], name='website_sna_tech_3d6d2f_gin'), django.contrib.postgres.indexes.GinIndex(fields=['response_headers'], name='ws_snap_resp_hdr_trgm', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['url'], name='ws_snap_url_trgm', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['title'], name='ws_snap_title_trgm', opclasses=['gin_trgm_ops'])],
'constraints': [models.UniqueConstraint(fields=('scan', 'url'), name='unique_website_per_scan_snapshot')],
},
),
]

View File

@@ -0,0 +1,101 @@
"""
创建资产搜索 IMMV增量维护物化视图
使用 pg_ivm 扩展创建 IMMV数据变更时自动增量更新无需手动刷新。
"""
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('asset', '0001_initial'),
]
operations = [
# 1. 确保 pg_ivm 扩展已启用
migrations.RunSQL(
sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;",
reverse_sql="-- pg_ivm extension kept for other uses"
),
# 2. 使用 pg_ivm 创建 IMMV
migrations.RunSQL(
sql="""
SELECT pgivm.create_immv('asset_search_view', $$
SELECT
w.id,
w.url,
w.host,
w.title,
w.tech,
w.status_code,
w.response_headers,
w.response_body,
w.created_at,
w.target_id
FROM website w
$$);
""",
reverse_sql="SELECT pgivm.drop_immv('asset_search_view');"
),
# 3. 创建唯一索引(用于标识)
migrations.RunSQL(
sql="""
CREATE UNIQUE INDEX IF NOT EXISTS asset_search_view_id_idx
ON asset_search_view (id);
""",
reverse_sql="""
DROP INDEX IF EXISTS asset_search_view_id_idx;
"""
),
# 4. 创建搜索优化索引
migrations.RunSQL(
sql="""
-- host 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_host_trgm_idx
ON asset_search_view USING gin (host gin_trgm_ops);
-- title 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_title_trgm_idx
ON asset_search_view USING gin (title gin_trgm_ops);
-- url 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_url_trgm_idx
ON asset_search_view USING gin (url gin_trgm_ops);
-- response_headers 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_headers_trgm_idx
ON asset_search_view USING gin (response_headers gin_trgm_ops);
-- response_body 模糊搜索索引
CREATE INDEX IF NOT EXISTS asset_search_view_body_trgm_idx
ON asset_search_view USING gin (response_body gin_trgm_ops);
-- tech 数组索引
CREATE INDEX IF NOT EXISTS asset_search_view_tech_idx
ON asset_search_view USING gin (tech);
-- status_code 索引
CREATE INDEX IF NOT EXISTS asset_search_view_status_idx
ON asset_search_view (status_code);
-- created_at 排序索引
CREATE INDEX IF NOT EXISTS asset_search_view_created_idx
ON asset_search_view (created_at DESC);
""",
reverse_sql="""
DROP INDEX IF EXISTS asset_search_view_host_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_title_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_url_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_headers_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_body_trgm_idx;
DROP INDEX IF EXISTS asset_search_view_tech_idx;
DROP INDEX IF EXISTS asset_search_view_status_idx;
DROP INDEX IF EXISTS asset_search_view_created_idx;
"""
),
]

View File

@@ -0,0 +1,328 @@
"""
资产搜索服务
提供资产搜索的核心业务逻辑:
- 从物化视图查询数据
- 支持表达式语法解析
- 支持 =(模糊)、==(精确)、!=(不等于)操作符
- 支持 && (AND) 和 || (OR) 逻辑组合
"""
import logging
import re
from typing import Optional, List, Dict, Any, Tuple
from django.db import connection
logger = logging.getLogger(__name__)
# 支持的字段映射(前端字段名 -> 数据库字段名)
FIELD_MAPPING = {
'host': 'host',
'url': 'url',
'title': 'title',
'tech': 'tech',
'status': 'status_code',
'body': 'response_body',
'header': 'response_headers',
}
# 数组类型字段
ARRAY_FIELDS = {'tech'}
class SearchQueryParser:
"""
搜索查询解析器
支持语法:
- field="value" 模糊匹配ILIKE %value%
- field=="value" 精确匹配
- field!="value" 不等于
- && AND 连接
- || OR 连接
- () 分组(暂不支持嵌套)
示例:
- host="api" && tech="nginx"
- tech="vue" || tech="react"
- status=="200" && host!="test"
"""
# 匹配单个条件: field="value" 或 field=="value" 或 field!="value"
CONDITION_PATTERN = re.compile(r'(\w+)\s*(==|!=|=)\s*"([^"]*)"')
@classmethod
def parse(cls, query: str) -> Tuple[str, List[Any]]:
"""
解析查询字符串,返回 SQL WHERE 子句和参数
Args:
query: 搜索查询字符串
Returns:
(where_clause, params) 元组
"""
if not query or not query.strip():
return "1=1", []
query = query.strip()
# 检查是否包含操作符语法,如果不包含则作为 host 模糊搜索
if not cls.CONDITION_PATTERN.search(query):
# 裸文本,默认作为 host 模糊搜索
return "host ILIKE %s", [f"%{query}%"]
# 按 || 分割为 OR 组
or_groups = cls._split_by_or(query)
if len(or_groups) == 1:
# 没有 OR直接解析 AND 条件
return cls._parse_and_group(or_groups[0])
# 多个 OR 组
or_clauses = []
all_params = []
for group in or_groups:
clause, params = cls._parse_and_group(group)
if clause and clause != "1=1":
or_clauses.append(f"({clause})")
all_params.extend(params)
if not or_clauses:
return "1=1", []
return " OR ".join(or_clauses), all_params
@classmethod
def _split_by_or(cls, query: str) -> List[str]:
"""按 || 分割查询,但忽略引号内的 ||"""
parts = []
current = ""
in_quotes = False
i = 0
while i < len(query):
char = query[i]
if char == '"':
in_quotes = not in_quotes
current += char
elif not in_quotes and i + 1 < len(query) and query[i:i+2] == '||':
if current.strip():
parts.append(current.strip())
current = ""
i += 1 # 跳过第二个 |
else:
current += char
i += 1
if current.strip():
parts.append(current.strip())
return parts if parts else [query]
@classmethod
def _parse_and_group(cls, group: str) -> Tuple[str, List[Any]]:
"""解析 AND 组(用 && 连接的条件)"""
# 移除外层括号
group = group.strip()
if group.startswith('(') and group.endswith(')'):
group = group[1:-1].strip()
# 按 && 分割
parts = cls._split_by_and(group)
and_clauses = []
all_params = []
for part in parts:
clause, params = cls._parse_condition(part.strip())
if clause:
and_clauses.append(clause)
all_params.extend(params)
if not and_clauses:
return "1=1", []
return " AND ".join(and_clauses), all_params
@classmethod
def _split_by_and(cls, query: str) -> List[str]:
"""按 && 分割查询,但忽略引号内的 &&"""
parts = []
current = ""
in_quotes = False
i = 0
while i < len(query):
char = query[i]
if char == '"':
in_quotes = not in_quotes
current += char
elif not in_quotes and i + 1 < len(query) and query[i:i+2] == '&&':
if current.strip():
parts.append(current.strip())
current = ""
i += 1 # 跳过第二个 &
else:
current += char
i += 1
if current.strip():
parts.append(current.strip())
return parts if parts else [query]
@classmethod
def _parse_condition(cls, condition: str) -> Tuple[Optional[str], List[Any]]:
"""
解析单个条件
Returns:
(sql_clause, params) 或 (None, []) 如果解析失败
"""
# 移除括号
condition = condition.strip()
if condition.startswith('(') and condition.endswith(')'):
condition = condition[1:-1].strip()
match = cls.CONDITION_PATTERN.match(condition)
if not match:
logger.warning(f"无法解析条件: {condition}")
return None, []
field, operator, value = match.groups()
field = field.lower()
# 验证字段
if field not in FIELD_MAPPING:
logger.warning(f"未知字段: {field}")
return None, []
db_field = FIELD_MAPPING[field]
is_array = field in ARRAY_FIELDS
# 根据操作符生成 SQL
if operator == '=':
# 模糊匹配
return cls._build_like_condition(db_field, value, is_array)
elif operator == '==':
# 精确匹配
return cls._build_exact_condition(db_field, value, is_array)
elif operator == '!=':
# 不等于
return cls._build_not_equal_condition(db_field, value, is_array)
return None, []
@classmethod
def _build_like_condition(cls, field: str, value: str, is_array: bool) -> Tuple[str, List[Any]]:
"""构建模糊匹配条件"""
if is_array:
# 数组字段:检查数组中是否有元素包含该值
return f"EXISTS (SELECT 1 FROM unnest({field}) AS t WHERE t ILIKE %s)", [f"%{value}%"]
else:
return f"{field} ILIKE %s", [f"%{value}%"]
@classmethod
def _build_exact_condition(cls, field: str, value: str, is_array: bool) -> Tuple[str, List[Any]]:
"""构建精确匹配条件"""
if is_array:
# 数组字段:检查数组中是否包含该精确值
return f"%s = ANY({field})", [value]
elif field == 'status_code':
# 状态码是整数
try:
return f"{field} = %s", [int(value)]
except ValueError:
return f"{field}::text = %s", [value]
else:
return f"{field} = %s", [value]
@classmethod
def _build_not_equal_condition(cls, field: str, value: str, is_array: bool) -> Tuple[str, List[Any]]:
"""构建不等于条件"""
if is_array:
# 数组字段:检查数组中不包含该值
return f"NOT (%s = ANY({field}))", [value]
elif field == 'status_code':
try:
return f"({field} IS NULL OR {field} != %s)", [int(value)]
except ValueError:
return f"({field} IS NULL OR {field}::text != %s)", [value]
else:
return f"({field} IS NULL OR {field} != %s)", [value]
class AssetSearchService:
"""资产搜索服务"""
def search(self, query: str) -> List[Dict[str, Any]]:
"""
搜索资产
Args:
query: 搜索查询字符串
Returns:
List[Dict]: 搜索结果列表
"""
where_clause, params = SearchQueryParser.parse(query)
sql = f"""
SELECT
id,
url,
host,
title,
tech,
status_code,
response_headers,
response_body,
target_id
FROM asset_search_view
WHERE {where_clause}
ORDER BY created_at DESC
"""
try:
with connection.cursor() as cursor:
cursor.execute(sql, params)
columns = [col[0] for col in cursor.description]
results = []
for row in cursor.fetchall():
result = dict(zip(columns, row))
results.append(result)
return results
except Exception as e:
logger.error(f"搜索查询失败: {e}, SQL: {sql}, params: {params}")
raise
def count(self, query: str) -> int:
"""
统计搜索结果数量
Args:
query: 搜索查询字符串
Returns:
int: 结果总数
"""
where_clause, params = SearchQueryParser.parse(query)
sql = f"SELECT COUNT(*) FROM asset_search_view WHERE {where_clause}"
try:
with connection.cursor() as cursor:
cursor.execute(sql, params)
return cursor.fetchone()[0]
except Exception as e:
logger.error(f"统计查询失败: {e}")
raise

View File

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

View File

@@ -10,6 +10,7 @@ from .views import (
DirectoryViewSet,
VulnerabilityViewSet,
AssetStatisticsViewSet,
AssetSearchView,
)
# 创建 DRF 路由器
@@ -25,4 +26,5 @@ router.register(r'statistics', AssetStatisticsViewSet, basename='asset-statistic
urlpatterns = [
path('assets/', include(router.urls)),
path('assets/search/', AssetSearchView.as_view(), name='asset-search'),
]

View File

@@ -0,0 +1,39 @@
"""
Asset 应用视图模块
重新导出所有视图类以保持向后兼容
"""
from .asset_views import (
AssetStatisticsViewSet,
SubdomainViewSet,
WebSiteViewSet,
DirectoryViewSet,
EndpointViewSet,
HostPortMappingViewSet,
VulnerabilityViewSet,
SubdomainSnapshotViewSet,
WebsiteSnapshotViewSet,
DirectorySnapshotViewSet,
EndpointSnapshotViewSet,
HostPortMappingSnapshotViewSet,
VulnerabilitySnapshotViewSet,
)
from .search_views import AssetSearchView
__all__ = [
'AssetStatisticsViewSet',
'SubdomainViewSet',
'WebSiteViewSet',
'DirectoryViewSet',
'EndpointViewSet',
'HostPortMappingViewSet',
'VulnerabilityViewSet',
'SubdomainSnapshotViewSet',
'WebsiteSnapshotViewSet',
'DirectorySnapshotViewSet',
'EndpointSnapshotViewSet',
'HostPortMappingSnapshotViewSet',
'VulnerabilitySnapshotViewSet',
'AssetSearchView',
]

View File

@@ -10,17 +10,17 @@ from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.db import DatabaseError, IntegrityError, OperationalError
from django.http import StreamingHttpResponse
from .serializers import (
from ..serializers import (
SubdomainListSerializer, WebSiteSerializer, DirectorySerializer,
VulnerabilitySerializer, EndpointListSerializer, IPAddressAggregatedSerializer,
SubdomainSnapshotSerializer, WebsiteSnapshotSerializer, DirectorySnapshotSerializer,
EndpointSnapshotSerializer, VulnerabilitySnapshotSerializer
)
from .services import (
from ..services import (
SubdomainService, WebSiteService, DirectoryService,
VulnerabilityService, AssetStatisticsService, EndpointService, HostPortMappingService
)
from .services.snapshot import (
from ..services.snapshot import (
SubdomainSnapshotsService, WebsiteSnapshotsService, DirectorySnapshotsService,
EndpointSnapshotsService, HostPortMappingSnapshotsService, VulnerabilitySnapshotsService
)

View File

@@ -0,0 +1,235 @@
"""
资产搜索 API 视图
提供资产搜索的 REST API 接口:
- GET /api/assets/search/ - 搜索资产
搜索语法:
- field="value" 模糊匹配ILIKE %value%
- field=="value" 精确匹配
- field!="value" 不等于
- && AND 连接
- || OR 连接
支持的字段:
- host: 主机名
- url: URL
- title: 标题
- tech: 技术栈
- status: 状态码
- body: 响应体
- header: 响应头
"""
import logging
import json
from urllib.parse import urlparse, urlunparse
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.request import Request
from django.db import connection
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
from apps.asset.services.search_service import AssetSearchService
logger = logging.getLogger(__name__)
class AssetSearchView(APIView):
"""
资产搜索 API
GET /api/assets/search/
Query Parameters:
q: 搜索查询表达式
page: 页码(从 1 开始,默认 1
pageSize: 每页数量(默认 10最大 100
示例查询:
?q=host="api" && tech="nginx"
?q=tech="vue" || tech="react"
?q=status=="200" && host!="test"
Response:
{
"results": [...],
"total": 100,
"page": 1,
"pageSize": 10,
"totalPages": 10
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.service = AssetSearchService()
def _parse_headers(self, headers_data) -> dict:
"""解析响应头为字典"""
if not headers_data:
return {}
try:
return json.loads(headers_data)
except (json.JSONDecodeError, TypeError):
result = {}
for line in str(headers_data).split('\n'):
if ':' in line:
key, value = line.split(':', 1)
result[key.strip()] = value.strip()
return result
def _format_result(self, result: dict, vulnerabilities_by_url: dict) -> dict:
"""格式化单个搜索结果"""
website_url = result.get('url', '')
vulns = vulnerabilities_by_url.get(website_url, [])
return {
'url': website_url,
'host': result.get('host', ''),
'title': result.get('title', ''),
'technologies': result.get('tech', []) or [],
'statusCode': result.get('status_code'),
'responseHeaders': self._parse_headers(result.get('response_headers')),
'responseBody': result.get('response_body', ''),
'vulnerabilities': [
{
'id': v.get('id'),
'name': v.get('vuln_type', ''),
'vulnType': v.get('vuln_type', ''),
'severity': v.get('severity', 'info'),
}
for v in vulns
],
}
def _get_vulnerabilities_by_url_prefix(self, website_urls: list) -> dict:
"""
根据 URL 前缀批量查询漏洞数据
漏洞 URL 是 website URL 的子路径,使用前缀匹配:
- website.url: https://example.com/path?query=1
- vulnerability.url: https://example.com/path/api/users
Args:
website_urls: website URL 列表,格式为 [(url, target_id), ...]
Returns:
dict: {website_url: [vulnerability_list]}
"""
if not website_urls:
return {}
try:
with connection.cursor() as cursor:
# 构建 OR 条件:每个 website URL去掉查询参数作为前缀匹配
conditions = []
params = []
url_mapping = {} # base_url -> original_url
for url, target_id in website_urls:
if not url or target_id is None:
continue
# 使用 urlparse 去掉查询参数和片段,只保留 scheme://netloc/path
parsed = urlparse(url)
base_url = urlunparse((parsed.scheme, parsed.netloc, parsed.path, '', '', ''))
url_mapping[base_url] = url
conditions.append("(v.url LIKE %s AND v.target_id = %s)")
params.extend([base_url + '%', target_id])
if not conditions:
return {}
where_clause = " OR ".join(conditions)
sql = f"""
SELECT v.id, v.vuln_type, v.severity, v.url, v.target_id
FROM vulnerability v
WHERE {where_clause}
ORDER BY
CASE v.severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5
END
"""
cursor.execute(sql, params)
# 获取所有漏洞
all_vulns = []
for row in cursor.fetchall():
all_vulns.append({
'id': row[0],
'vuln_type': row[1],
'name': row[1],
'severity': row[2],
'url': row[3],
'target_id': row[4],
})
# 按原始 website URL 分组(用于返回结果)
result = {url: [] for url, _ in website_urls}
for vuln in all_vulns:
vuln_url = vuln['url']
# 找到匹配的 website URL最长前缀匹配
for website_url, target_id in website_urls:
parsed = urlparse(website_url)
base_url = urlunparse((parsed.scheme, parsed.netloc, parsed.path, '', '', ''))
if vuln_url.startswith(base_url) and vuln['target_id'] == target_id:
result[website_url].append(vuln)
break
return result
except Exception as e:
logger.error(f"批量查询漏洞失败: {e}")
return {}
def get(self, request: Request):
"""搜索资产"""
# 获取搜索查询
query = request.query_params.get('q', '').strip()
if not query:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Search query (q) is required',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取分页参数
try:
page = int(request.query_params.get('page', 1))
page_size = int(request.query_params.get('pageSize', 10))
except (ValueError, TypeError):
page = 1
page_size = 10
# 限制分页参数
page = max(1, page)
page_size = min(max(1, page_size), 100)
# 获取总数和搜索结果
total = self.service.count(query)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
offset = (page - 1) * page_size
all_results = self.service.search(query)
results = all_results[offset:offset + page_size]
# 批量查询漏洞数据(按 URL 前缀匹配)
website_urls = [(r.get('url'), r.get('target_id')) for r in results if r.get('url') and r.get('target_id')]
vulnerabilities_by_url = self._get_vulnerabilities_by_url_prefix(website_urls) if website_urls else {}
# 格式化结果
formatted_results = [self._format_result(r, vulnerabilities_by_url) for r in results]
return success_response(data={
'results': formatted_results,
'total': total,
'page': page,
'pageSize': page_size,
'totalPages': total_pages,
})

View File

@@ -0,0 +1,213 @@
# Generated by Django 5.2.7 on 2026-01-02 04:45
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='NucleiTemplateRepo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='仓库名称,用于前端展示和配置引用', max_length=200, unique=True)),
('repo_url', models.CharField(help_text='Git 仓库地址', max_length=500)),
('local_path', models.CharField(blank=True, default='', help_text='本地工作目录绝对路径', max_length=500)),
('commit_hash', models.CharField(blank=True, default='', help_text='最后同步的 Git commit hash用于 Worker 版本校验', max_length=40)),
('last_synced_at', models.DateTimeField(blank=True, help_text='最后一次成功同步时间', null=True)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
],
options={
'verbose_name': 'Nuclei 模板仓库',
'verbose_name_plural': 'Nuclei 模板仓库',
'db_table': 'nuclei_template_repo',
},
),
migrations.CreateModel(
name='ARLFingerprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='指纹名称', max_length=300, unique=True)),
('rule', models.TextField(help_text='匹配规则表达式')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'ARL 指纹',
'verbose_name_plural': 'ARL 指纹',
'db_table': 'arl_fingerprint',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['name'], name='arl_fingerp_name_c3a305_idx'), models.Index(fields=['-created_at'], name='arl_fingerp_created_ed1060_idx')],
},
),
migrations.CreateModel(
name='EholeFingerprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cms', models.CharField(help_text='产品/CMS名称', max_length=200)),
('method', models.CharField(default='keyword', help_text='匹配方式', max_length=200)),
('location', models.CharField(default='body', help_text='匹配位置', max_length=200)),
('keyword', models.JSONField(default=list, help_text='关键词列表')),
('is_important', models.BooleanField(default=False, help_text='是否重点资产')),
('type', models.CharField(blank=True, default='-', help_text='分类', max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'EHole 指纹',
'verbose_name_plural': 'EHole 指纹',
'db_table': 'ehole_fingerprint',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['cms'], name='ehole_finge_cms_72ca2c_idx'), models.Index(fields=['method'], name='ehole_finge_method_17f0db_idx'), models.Index(fields=['location'], name='ehole_finge_locatio_7bb82b_idx'), models.Index(fields=['type'], name='ehole_finge_type_ca2bce_idx'), models.Index(fields=['is_important'], name='ehole_finge_is_impo_d56e64_idx'), models.Index(fields=['-created_at'], name='ehole_finge_created_d862b0_idx')],
'constraints': [models.UniqueConstraint(fields=('cms', 'method', 'location'), name='unique_ehole_fingerprint')],
},
),
migrations.CreateModel(
name='FingerPrintHubFingerprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('fp_id', models.CharField(help_text='指纹ID', max_length=200, unique=True)),
('name', models.CharField(help_text='指纹名称', max_length=300)),
('author', models.CharField(blank=True, default='', help_text='作者', max_length=200)),
('tags', models.CharField(blank=True, default='', help_text='标签', max_length=500)),
('severity', models.CharField(blank=True, default='info', help_text='严重程度', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict, help_text='元数据')),
('http', models.JSONField(default=list, help_text='HTTP 匹配规则')),
('source_file', models.CharField(blank=True, default='', help_text='来源文件', max_length=500)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'FingerPrintHub 指纹',
'verbose_name_plural': 'FingerPrintHub 指纹',
'db_table': 'fingerprinthub_fingerprint',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['fp_id'], name='fingerprint_fp_id_df467f_idx'), models.Index(fields=['name'], name='fingerprint_name_95b6fb_idx'), models.Index(fields=['author'], name='fingerprint_author_80f54b_idx'), models.Index(fields=['severity'], name='fingerprint_severit_f70422_idx'), models.Index(fields=['-created_at'], name='fingerprint_created_bec16c_idx')],
},
),
migrations.CreateModel(
name='FingersFingerprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='指纹名称', max_length=300, unique=True)),
('link', models.URLField(blank=True, default='', help_text='相关链接', max_length=500)),
('rule', models.JSONField(default=list, help_text='匹配规则数组')),
('tag', models.JSONField(default=list, help_text='标签数组')),
('focus', models.BooleanField(default=False, help_text='是否重点关注')),
('default_port', models.JSONField(blank=True, default=list, help_text='默认端口数组')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Fingers 指纹',
'verbose_name_plural': 'Fingers 指纹',
'db_table': 'fingers_fingerprint',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['name'], name='fingers_fin_name_952de0_idx'), models.Index(fields=['link'], name='fingers_fin_link_4c6b7f_idx'), models.Index(fields=['focus'], name='fingers_fin_focus_568c7f_idx'), models.Index(fields=['-created_at'], name='fingers_fin_created_46fc91_idx')],
},
),
migrations.CreateModel(
name='GobyFingerprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='产品名称', max_length=300, unique=True)),
('logic', models.CharField(help_text='逻辑表达式', max_length=500)),
('rule', models.JSONField(default=list, help_text='规则数组')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Goby 指纹',
'verbose_name_plural': 'Goby 指纹',
'db_table': 'goby_fingerprint',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['name'], name='goby_finger_name_82084c_idx'), models.Index(fields=['logic'], name='goby_finger_logic_a63226_idx'), models.Index(fields=['-created_at'], name='goby_finger_created_50e000_idx')],
},
),
migrations.CreateModel(
name='ScanEngine',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(help_text='引擎名称', max_length=200, unique=True)),
('configuration', models.CharField(blank=True, default='', help_text='引擎配置yaml 格式', max_length=10000)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
],
options={
'verbose_name': '扫描引擎',
'verbose_name_plural': '扫描引擎',
'db_table': 'scan_engine',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='scan_engine_created_da4870_idx')],
},
),
migrations.CreateModel(
name='WappalyzerFingerprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='应用名称', max_length=300, unique=True)),
('cats', models.JSONField(default=list, help_text='分类 ID 数组')),
('cookies', models.JSONField(blank=True, default=dict, help_text='Cookie 检测规则')),
('headers', models.JSONField(blank=True, default=dict, help_text='HTTP Header 检测规则')),
('script_src', models.JSONField(blank=True, default=list, help_text='脚本 URL 正则数组')),
('js', models.JSONField(blank=True, default=list, help_text='JavaScript 变量检测规则')),
('implies', models.JSONField(blank=True, default=list, help_text='依赖关系数组')),
('meta', models.JSONField(blank=True, default=dict, help_text='HTML meta 标签检测规则')),
('html', models.JSONField(blank=True, default=list, help_text='HTML 内容正则数组')),
('description', models.TextField(blank=True, default='', help_text='应用描述')),
('website', models.URLField(blank=True, default='', help_text='官网链接', max_length=500)),
('cpe', models.CharField(blank=True, default='', help_text='CPE 标识符', max_length=300)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Wappalyzer 指纹',
'verbose_name_plural': 'Wappalyzer 指纹',
'db_table': 'wappalyzer_fingerprint',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['name'], name='wappalyzer__name_63c669_idx'), models.Index(fields=['website'], name='wappalyzer__website_88de1c_idx'), models.Index(fields=['cpe'], name='wappalyzer__cpe_30c761_idx'), models.Index(fields=['-created_at'], name='wappalyzer__created_8e6c21_idx')],
},
),
migrations.CreateModel(
name='Wordlist',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(help_text='字典名称,唯一', max_length=200, unique=True)),
('description', models.CharField(blank=True, default='', help_text='字典描述', max_length=200)),
('file_path', models.CharField(help_text='后端保存的字典文件绝对路径', max_length=500)),
('file_size', models.BigIntegerField(default=0, help_text='文件大小(字节)')),
('line_count', models.IntegerField(default=0, help_text='字典行数')),
('file_hash', models.CharField(blank=True, default='', help_text='文件 SHA-256 哈希,用于缓存校验', max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
],
options={
'verbose_name': '字典文件',
'verbose_name_plural': '字典文件',
'db_table': 'wordlist',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='wordlist_created_4afb02_idx')],
},
),
migrations.CreateModel(
name='WorkerNode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='节点名称', max_length=100)),
('ip_address', models.GenericIPAddressField(help_text='IP 地址(本地节点为 127.0.0.1')),
('ssh_port', models.IntegerField(default=22, help_text='SSH 端口')),
('username', models.CharField(default='root', help_text='SSH 用户名', max_length=50)),
('password', models.CharField(blank=True, default='', help_text='SSH 密码', max_length=200)),
('is_local', models.BooleanField(default=False, help_text='是否为本地节点Docker 容器内)')),
('status', models.CharField(choices=[('pending', '待部署'), ('deploying', '部署中'), ('online', '在线'), ('offline', '离线'), ('updating', '更新中'), ('outdated', '版本过低')], default='pending', help_text='状态: pending/deploying/online/offline', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Worker 节点',
'db_table': 'worker_node',
'ordering': ['-created_at'],
'constraints': [models.UniqueConstraint(condition=models.Q(('is_local', False)), fields=('ip_address',), name='unique_remote_worker_ip'), models.UniqueConstraint(fields=('name',), name='unique_worker_name')],
},
),
]

View File

@@ -88,6 +88,8 @@ def _register_scheduled_jobs(scheduler: BackgroundScheduler):
replace_existing=True,
)
logger.info(" - 已注册: 扫描结果清理(每天 03:00")
# 注意:搜索物化视图刷新已迁移到 pg_ivm 增量维护,无需定时任务
def _trigger_scheduled_scans():

View File

@@ -162,6 +162,8 @@ def on_initiate_scan_flow_completed(flow: Flow, flow_run: FlowRun, state: State)
# 执行状态更新并获取统计数据
stats = _update_completed_status()
# 注意:物化视图刷新已迁移到 pg_ivm 增量维护,无需手动标记刷新
# 发送通知(包含统计摘要)
logger.info("准备发送扫描完成通知 - Scan ID: %s, Target: %s", scan_id, target_name)
try:

View File

@@ -0,0 +1,119 @@
# Generated by Django 5.2.7 on 2026-01-02 04:45
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('engine', '0001_initial'),
('targets', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='NotificationSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('discord_enabled', models.BooleanField(default=False, help_text='是否启用 Discord 通知')),
('discord_webhook_url', models.URLField(blank=True, default='', help_text='Discord Webhook URL')),
('categories', models.JSONField(default=dict, help_text='各分类通知开关,如 {"scan": true, "vulnerability": true, "asset": true, "system": false}')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '通知设置',
'verbose_name_plural': '通知设置',
'db_table': 'notification_settings',
},
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('category', models.CharField(choices=[('scan', '扫描任务'), ('vulnerability', '漏洞发现'), ('asset', '资产发现'), ('system', '系统消息')], db_index=True, default='system', help_text='通知分类', max_length=20)),
('level', models.CharField(choices=[('low', ''), ('medium', ''), ('high', ''), ('critical', '严重')], db_index=True, default='low', help_text='通知级别', max_length=20)),
('title', models.CharField(help_text='通知标题', max_length=200)),
('message', models.CharField(help_text='通知内容', max_length=2000)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('is_read', models.BooleanField(default=False, help_text='是否已读')),
('read_at', models.DateTimeField(blank=True, help_text='阅读时间', null=True)),
],
options={
'verbose_name': '通知',
'verbose_name_plural': '通知',
'db_table': 'notification',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='notificatio_created_c430f0_idx'), models.Index(fields=['category', '-created_at'], name='notificatio_categor_df0584_idx'), models.Index(fields=['level', '-created_at'], name='notificatio_level_0e5d12_idx'), models.Index(fields=['is_read', '-created_at'], name='notificatio_is_read_518ce0_idx')],
},
),
migrations.CreateModel(
name='Scan',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('engine_ids', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, help_text='引擎 ID 列表', size=None)),
('engine_names', models.JSONField(default=list, help_text='引擎名称列表,如 ["引擎A", "引擎B"]')),
('merged_configuration', models.TextField(default='', help_text='合并后的 YAML 配置')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='任务创建时间')),
('stopped_at', models.DateTimeField(blank=True, help_text='扫描结束时间', null=True)),
('status', models.CharField(choices=[('cancelled', '已取消'), ('completed', '已完成'), ('failed', '失败'), ('initiated', '初始化'), ('running', '运行中')], db_index=True, default='initiated', help_text='任务状态', max_length=20)),
('results_dir', models.CharField(blank=True, default='', help_text='结果存储目录', max_length=100)),
('container_ids', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, default=list, help_text='容器 ID 列表Docker Container ID', size=None)),
('error_message', models.CharField(blank=True, default='', help_text='错误信息', max_length=2000)),
('deleted_at', models.DateTimeField(blank=True, db_index=True, help_text='删除时间NULL表示未删除', null=True)),
('progress', models.IntegerField(default=0, help_text='扫描进度 0-100')),
('current_stage', models.CharField(blank=True, default='', help_text='当前扫描阶段', max_length=50)),
('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(blank=True, help_text='统计数据最后更新时间', null=True)),
('target', models.ForeignKey(help_text='扫描目标', on_delete=django.db.models.deletion.CASCADE, related_name='scans', to='targets.target')),
('worker', models.ForeignKey(blank=True, help_text='执行扫描的 Worker 节点', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scans', to='engine.workernode')),
],
options={
'verbose_name': '扫描任务',
'verbose_name_plural': '扫描任务',
'db_table': 'scan',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='scan_created_0bb6c7_idx'), models.Index(fields=['target'], name='scan_target__718b9d_idx'), models.Index(fields=['deleted_at', '-created_at'], name='scan_deleted_eb17e8_idx')],
},
),
migrations.CreateModel(
name='ScheduledScan',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(help_text='任务名称', max_length=200)),
('engine_ids', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, help_text='引擎 ID 列表', size=None)),
('engine_names', models.JSONField(default=list, help_text='引擎名称列表,如 ["引擎A", "引擎B"]')),
('merged_configuration', models.TextField(default='', help_text='合并后的 YAML 配置')),
('cron_expression', models.CharField(default='0 2 * * *', help_text='Cron 表达式,格式:分 时 日 月 周', max_length=100)),
('is_enabled', models.BooleanField(db_index=True, default=True, help_text='是否启用')),
('run_count', models.IntegerField(default=0, help_text='已执行次数')),
('last_run_time', models.DateTimeField(blank=True, help_text='上次执行时间', null=True)),
('next_run_time', models.DateTimeField(blank=True, help_text='下次执行时间', null=True)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
('organization', models.ForeignKey(blank=True, help_text='扫描组织(设置后执行时动态获取组织下所有目标)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_scans', to='targets.organization')),
('target', models.ForeignKey(blank=True, help_text='扫描单个目标(与 organization 二选一)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_scans', to='targets.target')),
],
options={
'verbose_name': '定时扫描任务',
'verbose_name_plural': '定时扫描任务',
'db_table': 'scheduled_scan',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='scheduled_s_created_9b9c2e_idx'), models.Index(fields=['is_enabled', '-created_at'], name='scheduled_s_is_enab_23d660_idx'), models.Index(fields=['name'], name='scheduled_s_name_bf332d_idx')],
},
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.2.7 on 2026-01-02 04:45
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Target',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(blank=True, default='', help_text='目标标识(域名/IP/CIDR', max_length=300)),
('type', models.CharField(choices=[('domain', '域名'), ('ip', 'IP地址'), ('cidr', 'CIDR范围')], db_index=True, default='domain', help_text='目标类型', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('last_scanned_at', models.DateTimeField(blank=True, help_text='最后扫描时间', null=True)),
('deleted_at', models.DateTimeField(blank=True, db_index=True, help_text='删除时间NULL表示未删除', null=True)),
],
options={
'verbose_name': '扫描目标',
'verbose_name_plural': '扫描目标',
'db_table': 'target',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['type'], name='target_type_36a73c_idx'), models.Index(fields=['-created_at'], name='target_created_67f489_idx'), models.Index(fields=['deleted_at', '-created_at'], name='target_deleted_9fc9da_idx'), models.Index(fields=['deleted_at', 'type'], name='target_deleted_306a89_idx'), models.Index(fields=['name'], name='target_name_f1c641_idx')],
'constraints': [models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('name',), name='unique_target_name_active')],
},
),
migrations.CreateModel(
name='Organization',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(blank=True, default='', help_text='组织名称', max_length=300)),
('description', models.CharField(blank=True, default='', help_text='组织描述', max_length=1000)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('deleted_at', models.DateTimeField(blank=True, db_index=True, help_text='删除时间NULL表示未删除', null=True)),
('targets', models.ManyToManyField(blank=True, help_text='所属目标列表', related_name='organizations', to='targets.target')),
],
options={
'verbose_name': '组织',
'verbose_name_plural': '组织',
'db_table': 'organization',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at'], name='organizatio_created_012eac_idx'), models.Index(fields=['deleted_at', '-created_at'], name='organizatio_deleted_2c604f_idx'), models.Index(fields=['name'], name='organizatio_name_bcc2ee_idx')],
'constraints': [models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('name',), name='unique_organization_name_active')],
},
),
]

View File

@@ -260,6 +260,12 @@ class TestDataGenerator:
def clear_data(self):
"""清除所有测试数据"""
cur = self.conn.cursor()
# 先删除 IMMV避免 pg_ivm 的 anyarray bug
print(" 删除 IMMV...")
cur.execute("DROP TABLE IF EXISTS asset_search_view CASCADE")
self.conn.commit()
tables = [
# 指纹表
'ehole_fingerprint', 'goby_fingerprint', 'wappalyzer_fingerprint',
@@ -276,6 +282,26 @@ class TestDataGenerator:
for table in tables:
cur.execute(f"DELETE FROM {table}")
self.conn.commit()
# 重建 IMMV
print(" 重建 IMMV...")
cur.execute("""
SELECT pgivm.create_immv('asset_search_view', $$
SELECT
w.id,
w.url,
w.host,
w.title,
w.tech,
w.status_code,
w.response_headers,
w.response_body,
w.created_at,
w.target_id
FROM website w
$$)
""")
self.conn.commit()
print(" ✓ 数据清除完成\n")
def create_workers(self) -> list:
@@ -1248,77 +1274,79 @@ class TestDataGenerator:
print(f" ✓ 创建了 {count} 个主机端口映射\n")
def create_vulnerabilities(self, target_ids: list):
"""创建漏洞"""
"""创建漏洞(基于 website URL 前缀)"""
print("🐛 创建漏洞...")
cur = self.conn.cursor()
vuln_types = [
'sql-injection-authentication-bypass-vulnerability-', # 50 chars
'cross-site-scripting-xss-stored-persistent-attack-', # 50 chars
'cross-site-request-forgery-csrf-token-validation--', # 50 chars
'server-side-request-forgery-ssrf-internal-access--', # 50 chars
'xml-external-entity-xxe-injection-vulnerability---', # 50 chars
'remote-code-execution-rce-command-injection-flaw--', # 50 chars
'local-file-inclusion-lfi-path-traversal-exploit---', # 50 chars
'directory-traversal-arbitrary-file-read-access----', # 50 chars
'authentication-bypass-session-management-flaw-----', # 50 chars
'insecure-direct-object-reference-idor-access-ctrl-', # 50 chars
'sensitive-data-exposure-information-disclosure----', # 50 chars
'security-misconfiguration-default-credentials-----', # 50 chars
'broken-access-control-privilege-escalation-vuln---', # 50 chars
'cors-misconfiguration-cross-origin-data-leakage---', # 50 chars
'subdomain-takeover-dns-misconfiguration-exploit---', # 50 chars
'exposed-admin-panel-unauthorized-access-control---', # 50 chars
'default-credentials-weak-authentication-bypass----', # 50 chars
'information-disclosure-sensitive-data-exposure----', # 50 chars
'command-injection-os-command-execution-exploit----', # 50 chars
'ldap-injection-directory-service-manipulation-----', # 50 chars
'xpath-injection-xml-query-manipulation-attack-----', # 50 chars
'nosql-injection-mongodb-query-manipulation--------', # 50 chars
'template-injection-ssti-server-side-execution-----', # 50 chars
'deserialization-vulnerability-object-injection----', # 50 chars
'jwt-vulnerability-token-forgery-authentication----', # 50 chars
'open-redirect-url-redirection-phishing-attack-----', # 50 chars
'http-request-smuggling-cache-poisoning-attack-----', # 50 chars
'host-header-injection-password-reset-poisoning----', # 50 chars
'clickjacking-ui-redressing-frame-injection--------', # 50 chars
'session-fixation-authentication-session-attack----', # 50 chars
'sql-injection-authentication-bypass-vulnerability-',
'cross-site-scripting-xss-stored-persistent-attack-',
'cross-site-request-forgery-csrf-token-validation--',
'server-side-request-forgery-ssrf-internal-access--',
'xml-external-entity-xxe-injection-vulnerability---',
'remote-code-execution-rce-command-injection-flaw--',
'local-file-inclusion-lfi-path-traversal-exploit---',
'directory-traversal-arbitrary-file-read-access----',
'authentication-bypass-session-management-flaw-----',
'insecure-direct-object-reference-idor-access-ctrl-',
'sensitive-data-exposure-information-disclosure----',
'security-misconfiguration-default-credentials-----',
'broken-access-control-privilege-escalation-vuln---',
'cors-misconfiguration-cross-origin-data-leakage---',
'subdomain-takeover-dns-misconfiguration-exploit---',
'exposed-admin-panel-unauthorized-access-control---',
'default-credentials-weak-authentication-bypass----',
'information-disclosure-sensitive-data-exposure----',
'command-injection-os-command-execution-exploit----',
'ldap-injection-directory-service-manipulation-----',
]
sources = [
'nuclei-vulnerability-scanner--', # 30 chars
'dalfox-xss-parameter-analysis-', # 30 chars
'sqlmap-sql-injection-testing--', # 30 chars
'crlfuzz-crlf-injection-finder-', # 30 chars
'httpx-web-probe-fingerprint---', # 30 chars
'manual-penetration-testing----', # 30 chars
'burp-suite-professional-scan--', # 30 chars
'owasp-zap-security-scanner----', # 30 chars
'nmap-network-service-scanner--', # 30 chars
'nikto-web-server-scanner------', # 30 chars
'wpscan-wordpress-vuln-scan----', # 30 chars
'dirsearch-directory-brute-----', # 30 chars
'ffuf-web-fuzzer-content-disc--', # 30 chars
'amass-subdomain-enumeration---', # 30 chars
'subfinder-passive-subdomain---', # 30 chars
'masscan-port-scanner-fast-----', # 30 chars
'nessus-vulnerability-assess---', # 30 chars
'qualys-cloud-security-scan----', # 30 chars
'acunetix-web-vuln-scanner-----', # 30 chars
'semgrep-static-code-analysis--', # 30 chars
'nuclei-vulnerability-scanner--',
'dalfox-xss-parameter-analysis-',
'sqlmap-sql-injection-testing--',
'crlfuzz-crlf-injection-finder-',
'httpx-web-probe-fingerprint---',
'manual-penetration-testing----',
'burp-suite-professional-scan--',
'owasp-zap-security-scanner----',
]
severities = ['unknown', 'info', 'low', 'medium', 'high', 'critical']
# 获取域名目标
cur.execute("SELECT id, name FROM target WHERE type = 'domain' AND deleted_at IS NULL LIMIT 80")
domain_targets = cur.fetchall()
# 漏洞路径后缀(会追加到 website URL 后面)
vuln_paths = [
'/api/users?id=1',
'/api/admin/config',
'/api/v1/auth/login',
'/api/v2/data/export',
'/admin/settings',
'/debug/console',
'/backup/db.sql',
'/.env',
'/.git/config',
'/wp-admin/',
'/phpmyadmin/',
'/api/graphql',
'/swagger.json',
'/actuator/health',
'/metrics',
]
# 获取所有 website 的 URL 和 target_id
cur.execute("SELECT id, url, target_id FROM website LIMIT 500")
websites = cur.fetchall()
if not websites:
print(" ⚠ 没有 website 数据,跳过漏洞生成\n")
return
count = 0
batch_data = []
for target_id, target_name in domain_targets:
num = random.randint(30, 80)
for website_id, website_url, target_id in websites:
# 每个 website 生成 1-5 个漏洞
num_vulns = random.randint(1, 5)
for idx in range(num):
for idx in range(num_vulns):
severity = random.choice(severities)
cvss_ranges = {
'critical': (9.0, 10.0), 'high': (7.0, 8.9), 'medium': (4.0, 6.9),
@@ -1327,22 +1355,22 @@ class TestDataGenerator:
cvss_range = cvss_ranges.get(severity, (0.0, 10.0))
cvss_score = round(random.uniform(*cvss_range), 1)
# 生成固定 245 长度的 URL
url = generate_fixed_length_url(target_name, length=245, path_hint=f'vuln/{idx:04d}')
# 漏洞 URL = website URL + 漏洞路径
# 先移除 website URL 中的查询参数
base_url = website_url.split('?')[0]
vuln_url = base_url + random.choice(vuln_paths)
# 生成固定 300 长度的描述
description = generate_fixed_length_text(length=300, text_type='description')
raw_output = json.dumps({
'template': f'CVE-2024-{random.randint(10000, 99999)}',
'matcher_name': 'default',
'severity': severity,
'host': target_name,
'matched_at': url,
'matched_at': vuln_url,
})
batch_data.append((
target_id, url, random.choice(vuln_types), severity,
target_id, vuln_url, random.choice(vuln_types), severity,
random.choice(sources), cvss_score, description, raw_output
))
count += 1

View File

@@ -2,9 +2,13 @@ services:
# PostgreSQL可选使用远程数据库时不启动
# 本地模式: docker compose --profile local-db up -d
# 远程模式: docker compose up -d需配置 DB_HOST 为远程地址)
# 使用自定义镜像,预装 pg_ivm 扩展
postgres:
profiles: ["local-db"]
image: postgres:15
build:
context: ./postgres
dockerfile: Dockerfile
image: ${DOCKER_USER:-yyhuni}/xingrin-postgres:15
restart: always
environment:
POSTGRES_DB: ${DB_NAME}
@@ -15,6 +19,9 @@ services:
- ./postgres/init-user-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh
ports:
- "${DB_PORT}:5432"
command: >
postgres
-c shared_preload_libraries=pg_ivm
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 5s

View File

@@ -8,9 +8,13 @@
services:
# PostgreSQL可选使用远程数据库时不启动
# 使用自定义镜像,预装 pg_ivm 扩展
postgres:
profiles: ["local-db"]
image: postgres:15
build:
context: ./postgres
dockerfile: Dockerfile
image: ${DOCKER_USER:-yyhuni}/xingrin-postgres:15
restart: always
environment:
POSTGRES_DB: ${DB_NAME}
@@ -21,6 +25,9 @@ services:
- ./postgres/init-user-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh
ports:
- "${DB_PORT}:5432"
command: >
postgres
-c shared_preload_libraries=pg_ivm
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 5s

View File

@@ -0,0 +1,19 @@
FROM postgres:15
# 安装编译依赖
RUN apt-get update && apt-get install -y \
build-essential \
postgresql-server-dev-15 \
git \
&& rm -rf /var/lib/apt/lists/*
# 编译安装 pg_ivm
RUN git clone https://github.com/sraoss/pg_ivm.git /tmp/pg_ivm \
&& cd /tmp/pg_ivm \
&& make \
&& make install \
&& rm -rf /tmp/pg_ivm
# 配置 shared_preload_libraries
# 注意: 这个配置会在容器启动时被应用
RUN echo "shared_preload_libraries = 'pg_ivm'" >> /usr/share/postgresql/postgresql.conf.sample

129
docker/scripts/install-pg-ivm.sh Executable file
View File

@@ -0,0 +1,129 @@
#!/bin/bash
# pg_ivm 一键安装脚本(用于远程自建 PostgreSQL 服务器)
# 要求: PostgreSQL 13+ 版本
set -e
echo "=========================================="
echo "pg_ivm 一键安装脚本"
echo "要求: PostgreSQL 13+ 版本"
echo "=========================================="
echo ""
# 检查是否以 root 运行
if [ "$EUID" -ne 0 ]; then
echo "错误: 请使用 sudo 运行此脚本"
exit 1
fi
# 检测 PostgreSQL 版本
detect_pg_version() {
if command -v psql &> /dev/null; then
psql --version | grep -oP '\d+' | head -1
elif [ -n "$PG_VERSION" ]; then
echo "$PG_VERSION"
else
echo "15"
fi
}
PG_VERSION=${PG_VERSION:-$(detect_pg_version)}
# 检测 PostgreSQL
if ! command -v psql &> /dev/null; then
echo "错误: 未检测到 PostgreSQL请先安装 PostgreSQL"
exit 1
fi
echo "检测到 PostgreSQL 版本: $PG_VERSION"
# 检查版本要求
if [ "$PG_VERSION" -lt 13 ]; then
echo "错误: pg_ivm 要求 PostgreSQL 13+ 版本,当前版本: $PG_VERSION"
exit 1
fi
# 安装编译依赖
echo ""
echo "[1/4] 安装编译依赖..."
if command -v apt-get &> /dev/null; then
apt-get update -qq
apt-get install -y -qq build-essential postgresql-server-dev-${PG_VERSION} git
elif command -v yum &> /dev/null; then
yum install -y gcc make git postgresql${PG_VERSION}-devel
else
echo "错误: 不支持的包管理器,请手动安装编译依赖"
exit 1
fi
echo "✓ 编译依赖安装完成"
# 编译安装 pg_ivm
echo ""
echo "[2/4] 编译安装 pg_ivm..."
rm -rf /tmp/pg_ivm
git clone --quiet https://github.com/sraoss/pg_ivm.git /tmp/pg_ivm
cd /tmp/pg_ivm
make -s
make install -s
rm -rf /tmp/pg_ivm
echo "✓ pg_ivm 编译安装完成"
# 配置 shared_preload_libraries
echo ""
echo "[3/4] 配置 shared_preload_libraries..."
PG_CONF_DIRS=(
"/etc/postgresql/${PG_VERSION}/main"
"/var/lib/pgsql/${PG_VERSION}/data"
"/var/lib/postgresql/data"
)
PG_CONF_DIR=""
for dir in "${PG_CONF_DIRS[@]}"; do
if [ -d "$dir" ]; then
PG_CONF_DIR="$dir"
break
fi
done
if [ -z "$PG_CONF_DIR" ]; then
echo "警告: 未找到 PostgreSQL 配置目录,请手动配置 shared_preload_libraries"
echo "在 postgresql.conf 中添加: shared_preload_libraries = 'pg_ivm'"
else
if grep -q "shared_preload_libraries.*pg_ivm" "$PG_CONF_DIR/postgresql.conf" 2>/dev/null; then
echo "✓ shared_preload_libraries 已配置"
else
if [ -d "$PG_CONF_DIR/conf.d" ]; then
echo "shared_preload_libraries = 'pg_ivm'" > "$PG_CONF_DIR/conf.d/pg_ivm.conf"
echo "✓ 配置已写入 $PG_CONF_DIR/conf.d/pg_ivm.conf"
else
if grep -q "^shared_preload_libraries" "$PG_CONF_DIR/postgresql.conf"; then
sed -i "s/^shared_preload_libraries = '\(.*\)'/shared_preload_libraries = '\1,pg_ivm'/" "$PG_CONF_DIR/postgresql.conf"
else
echo "shared_preload_libraries = 'pg_ivm'" >> "$PG_CONF_DIR/postgresql.conf"
fi
echo "✓ 配置已写入 $PG_CONF_DIR/postgresql.conf"
fi
fi
fi
# 重启 PostgreSQL
echo ""
echo "[4/4] 重启 PostgreSQL..."
if systemctl is-active --quiet postgresql; then
systemctl restart postgresql
echo "✓ PostgreSQL 已重启"
elif systemctl is-active --quiet postgresql-${PG_VERSION}; then
systemctl restart postgresql-${PG_VERSION}
echo "✓ PostgreSQL 已重启"
else
echo "警告: 无法自动重启 PostgreSQL请手动重启"
fi
echo ""
echo "=========================================="
echo "✓ pg_ivm 安装完成"
echo "=========================================="
echo ""
echo "验证安装:"
echo " psql -U postgres -c \"CREATE EXTENSION IF NOT EXISTS pg_ivm;\""
echo ""

126
docker/scripts/test-pg-ivm.sh Executable file
View File

@@ -0,0 +1,126 @@
#!/bin/bash
# pg_ivm 安装验证测试
# 在 Docker 容器中测试 install-pg-ivm.sh 的安装流程
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONTAINER_NAME="pg_ivm_test_$$"
IMAGE_NAME="postgres:15"
echo "=========================================="
echo "pg_ivm 安装验证测试"
echo "=========================================="
# 清理函数
cleanup() {
echo ""
echo "[清理] 删除测试容器..."
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
}
trap cleanup EXIT
# 1. 启动临时容器
echo ""
echo "[1/5] 启动临时 PostgreSQL 容器..."
docker run -d --name "$CONTAINER_NAME" \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_USER=postgres \
-e POSTGRES_DB=testdb \
-e PG_VERSION=15 \
"$IMAGE_NAME"
echo "等待 PostgreSQL 启动..."
sleep 10
if ! docker ps | grep -q "$CONTAINER_NAME"; then
echo "错误: 容器启动失败"
exit 1
fi
# 2. 复制并执行安装脚本
echo ""
echo "[2/5] 执行 pg_ivm 安装脚本..."
docker cp "$SCRIPT_DIR/install-pg-ivm.sh" "$CONTAINER_NAME:/tmp/install-pg-ivm.sh"
# 在容器内模拟安装(跳过 systemctl 重启,手动重启容器)
docker exec "$CONTAINER_NAME" bash -c "
set -e
export PG_VERSION=15
echo '安装编译依赖...'
apt-get update -qq
apt-get install -y -qq build-essential postgresql-server-dev-15 git
echo '编译安装 pg_ivm...'
rm -rf /tmp/pg_ivm
git clone --quiet https://github.com/sraoss/pg_ivm.git /tmp/pg_ivm
cd /tmp/pg_ivm
make -s
make install -s
rm -rf /tmp/pg_ivm
echo '✓ pg_ivm 编译安装完成'
"
# 3. 配置 shared_preload_libraries 并重启
echo ""
echo "[3/5] 配置 shared_preload_libraries..."
docker exec "$CONTAINER_NAME" bash -c "
echo \"shared_preload_libraries = 'pg_ivm'\" >> /var/lib/postgresql/data/postgresql.conf
"
echo "重启 PostgreSQL..."
docker restart "$CONTAINER_NAME"
sleep 8
# 4. 验证扩展是否可用
echo ""
echo "[4/5] 验证 pg_ivm 扩展..."
docker exec "$CONTAINER_NAME" psql -U postgres -d testdb -c "CREATE EXTENSION IF NOT EXISTS pg_ivm;" > /dev/null 2>&1
EXTENSION_EXISTS=$(docker exec "$CONTAINER_NAME" psql -U postgres -d testdb -t -c "SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_ivm';")
if [ "$(echo $EXTENSION_EXISTS | tr -d ' ')" != "1" ]; then
echo "错误: pg_ivm 扩展未正确加载"
exit 1
fi
echo "✓ pg_ivm 扩展已加载"
# 5. 测试 IMMV 功能
echo ""
echo "[5/5] 测试 IMMV 增量更新功能..."
docker exec "$CONTAINER_NAME" psql -U postgres -d testdb -c "
CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT, value INTEGER);
SELECT pgivm.create_immv('test_immv', 'SELECT id, name, value FROM test_table');
INSERT INTO test_table (name, value) VALUES ('test1', 100);
INSERT INTO test_table (name, value) VALUES ('test2', 200);
" > /dev/null 2>&1
IMMV_COUNT=$(docker exec "$CONTAINER_NAME" psql -U postgres -d testdb -t -c "SELECT COUNT(*) FROM test_immv;")
if [ "$(echo $IMMV_COUNT | tr -d ' ')" != "2" ]; then
echo "错误: IMMV 增量更新失败,期望 2 行,实际 $(echo $IMMV_COUNT | tr -d ' ')"
exit 1
fi
echo "✓ IMMV 增量更新正常 (2 行数据)"
# 测试更新
docker exec "$CONTAINER_NAME" psql -U postgres -d testdb -c "UPDATE test_table SET value = 150 WHERE name = 'test1';" > /dev/null 2>&1
UPDATED_VALUE=$(docker exec "$CONTAINER_NAME" psql -U postgres -d testdb -t -c "SELECT value FROM test_immv WHERE name = 'test1';")
if [ "$(echo $UPDATED_VALUE | tr -d ' ')" != "150" ]; then
echo "错误: IMMV 更新同步失败"
exit 1
fi
echo "✓ IMMV 更新同步正常"
# 测试删除
docker exec "$CONTAINER_NAME" psql -U postgres -d testdb -c "DELETE FROM test_table WHERE name = 'test2';" > /dev/null 2>&1
IMMV_COUNT_AFTER=$(docker exec "$CONTAINER_NAME" psql -U postgres -d testdb -t -c "SELECT COUNT(*) FROM test_immv;")
if [ "$(echo $IMMV_COUNT_AFTER | tr -d ' ')" != "1" ]; then
echo "错误: IMMV 删除同步失败"
exit 1
fi
echo "✓ IMMV 删除同步正常"
echo ""
echo "=========================================="
echo "✓ 所有测试通过"
echo "=========================================="
echo ""
echo "pg_ivm 安装验证成功,可以继续构建自定义 PostgreSQL 镜像"

View File

@@ -0,0 +1,5 @@
import { SearchPage } from "@/components/search"
export default function Search() {
return <SearchPage />
}

View File

@@ -44,7 +44,6 @@
--font-sans: 'Noto Sans SC', system-ui, -apple-system, PingFang SC, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
--font-serif: Georgia, 'Noto Serif SC', serif;
--radius: 0.625rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);

View File

@@ -16,6 +16,7 @@ import {
IconTerminal2, // Terminal icon
IconBug, // Vulnerability icon
IconMessageReport, // Feedback icon
IconSearch, // Search icon
} from "@tabler/icons-react"
// Import internationalization hook
import { useTranslations } from 'next-intl'
@@ -76,6 +77,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: "/dashboard/",
icon: IconDashboard,
},
{
title: t('search'),
url: "/search/",
icon: IconSearch,
},
{
title: t('organization'),
url: "/organization/",

View File

@@ -0,0 +1,3 @@
export { SearchPage } from "./search-page"
export { SearchResultCard } from "./search-result-card"
export { SearchPagination } from "./search-pagination"

View File

@@ -0,0 +1,263 @@
"use client"
import { useState, useCallback } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Search, AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { SmartFilterInput, type FilterField } from "@/components/common/smart-filter-input"
import { SearchResultCard } from "./search-result-card"
import { SearchPagination } from "./search-pagination"
import { useAssetSearch } from "@/hooks/use-search"
import { VulnerabilityDetailDialog } from "@/components/vulnerabilities/vulnerability-detail-dialog"
import { VulnerabilityService } from "@/services/vulnerability.service"
import type { SearchParams, SearchState, Vulnerability as SearchVuln } from "@/types/search.types"
import type { Vulnerability } from "@/types/vulnerability.types"
import { Alert, AlertDescription } from "@/components/ui/alert"
// 搜索示例 - 展示各种查询语法
const SEARCH_FILTER_EXAMPLES = [
// 模糊匹配 (=)
'host="api"',
'title="Dashboard"',
'tech="nginx"',
// 精确匹配 (==)
'status=="200"',
'host=="admin.example.com"',
// 不等于 (!=)
'status!="404"',
'host!="test"',
// AND 组合 (&&)
'host="api" && status=="200"',
'tech="nginx" && title="Dashboard"',
'host="admin" && tech="php" && status=="200"',
// OR 组合 (||)
'tech="vue" || tech="react"',
'status=="200" || status=="301"',
'host="admin" || host="manage"',
// 混合查询
'host="api" && (tech="nginx" || tech="apache")',
'(status=="200" || status=="301") && tech="vue"',
'host="example" && status!="404" && tech="nginx"',
]
export function SearchPage() {
const t = useTranslations('search')
const [searchState, setSearchState] = useState<SearchState>("initial")
const [query, setQuery] = useState("")
const [searchParams, setSearchParams] = useState<SearchParams>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [selectedVuln, setSelectedVuln] = useState<Vulnerability | null>(null)
const [vulnDialogOpen, setVulnDialogOpen] = useState(false)
const [loadingVuln, setLoadingVuln] = useState(false)
// 搜索过滤字段配置
const SEARCH_FILTER_FIELDS: FilterField[] = [
{ key: "host", label: "Host", description: t('fields.host') },
{ key: "url", label: "URL", description: t('fields.url') },
{ key: "title", label: "Title", description: t('fields.title') },
{ key: "tech", label: "Tech", description: t('fields.tech') },
{ key: "status", label: "Status", description: t('fields.status') },
{ key: "body", label: "Body", description: t('fields.body') },
{ key: "header", label: "Header", description: t('fields.header') },
]
// 使用搜索 Hook
const { data, isLoading, error, isFetching } = useAssetSearch(
{ ...searchParams, page, pageSize },
{ enabled: searchState === "results" || searchState === "searching" }
)
const handleSearch = useCallback((_filters: unknown, rawQuery: string) => {
if (!rawQuery.trim()) return
setQuery(rawQuery)
setSearchParams({ q: rawQuery })
setPage(1)
setSearchState("searching")
}, [])
// 当数据加载完成时更新状态
if (searchState === "searching" && data && !isLoading) {
setSearchState("results")
}
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage)
}, [])
const handlePageSizeChange = useCallback((newPageSize: number) => {
setPageSize(newPageSize)
setPage(1)
}, [])
const handleViewVulnerability = useCallback(async (vuln: SearchVuln) => {
if (!vuln.id) return
setLoadingVuln(true)
try {
const fullVuln = await VulnerabilityService.getVulnerabilityById(vuln.id)
setSelectedVuln(fullVuln)
setVulnDialogOpen(true)
} catch {
toast.error(t('vulnLoadError'))
} finally {
setLoadingVuln(false)
}
}, [t])
return (
<div className="flex-1 w-full flex flex-col">
<AnimatePresence mode="wait">
{searchState === "initial" && (
<motion.div
key="initial"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
transition={{ duration: 0.3 }}
className="flex-1 flex flex-col items-center justify-center px-4 -mt-50"
>
<div className="flex flex-col items-center gap-6 w-full max-w-2xl">
<h1 className="text-3xl font-semibold text-foreground flex items-center gap-3">
<Search className="h-8 w-8" />
{t('title')}
</h1>
<SmartFilterInput
fields={SEARCH_FILTER_FIELDS}
examples={SEARCH_FILTER_EXAMPLES}
placeholder='host="api" && tech="nginx" && status=="200"'
value={query}
onSearch={handleSearch}
className="w-full [&_input]:h-12 [&_input]:text-base [&_button]:h-12 [&_button]:w-12 [&_button]:p-0"
/>
<p className="text-sm text-muted-foreground">
{t('hint')}
</p>
</div>
</motion.div>
)}
{searchState === "searching" && isLoading && (
<motion.div
key="searching"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="h-full flex flex-col items-center justify-center"
>
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<span className="text-muted-foreground">{t('searching')}</span>
</div>
</motion.div>
)}
{(searchState === "results" || (searchState === "searching" && !isLoading)) && (
<motion.div
key="results"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="h-full flex flex-col"
>
{/* 顶部搜索栏 */}
<motion.div
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-4 py-3"
>
<div className="flex items-center gap-3 max-w-4xl mx-auto">
<SmartFilterInput
fields={SEARCH_FILTER_FIELDS}
examples={SEARCH_FILTER_EXAMPLES}
placeholder='host="api" && tech="nginx" && status=="200"'
value={query}
onSearch={handleSearch}
className="flex-1"
/>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{isFetching ? t('loading') : t('resultsCount', { count: data?.total ?? 0 })}
</span>
</div>
</motion.div>
{/* 错误提示 */}
{error && (
<div className="p-4 max-w-4xl mx-auto w-full">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t('error')}
</AlertDescription>
</Alert>
</div>
)}
{/* 空结果提示 */}
{!error && data?.results.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center p-4">
<div className="text-center">
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">{t('noResults')}</h3>
<p className="text-sm text-muted-foreground">
{t('noResultsHint')}
</p>
</div>
</div>
)}
{/* 搜索结果列表 */}
{!error && data && data.results.length > 0 && (
<>
<div className="flex-1 overflow-auto p-4">
<div className="max-w-4xl mx-auto space-y-4">
{data.results.map((result, index) => (
<motion.div
key={`${result.url}-${index}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<SearchResultCard
result={result}
onViewVulnerability={handleViewVulnerability}
/>
</motion.div>
))}
</div>
</div>
{/* 分页控制 */}
<div className="border-t px-4 py-3">
<div className="max-w-4xl mx-auto">
<SearchPagination
page={page}
pageSize={pageSize}
total={data.total}
totalPages={data.totalPages}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* 漏洞详情弹窗 - 复用现有组件 */}
<VulnerabilityDetailDialog
vulnerability={selectedVuln}
open={vulnDialogOpen}
onOpenChange={setVulnDialogOpen}
/>
</div>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import {
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
} from "@tabler/icons-react"
import { useTranslations } from 'next-intl'
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface SearchPaginationProps {
page: number
pageSize: number
total: number
totalPages: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
pageSizeOptions?: number[]
}
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
/**
* 搜索结果分页组件
*/
export function SearchPagination({
page,
pageSize,
total,
totalPages,
onPageChange,
onPageSizeChange,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
}: SearchPaginationProps) {
const t = useTranslations('common.pagination')
const handlePageSizeChange = React.useCallback((value: string) => {
onPageSizeChange(Number(value))
}, [onPageSizeChange])
const handleFirstPage = React.useCallback(() => {
onPageChange(1)
}, [onPageChange])
const handlePreviousPage = React.useCallback(() => {
onPageChange(Math.max(1, page - 1))
}, [onPageChange, page])
const handleNextPage = React.useCallback(() => {
onPageChange(Math.min(totalPages, page + 1))
}, [onPageChange, page, totalPages])
const handleLastPage = React.useCallback(() => {
onPageChange(totalPages)
}, [onPageChange, totalPages])
const canPreviousPage = page > 1
const canNextPage = page < totalPages
return (
<div className="flex items-center justify-between">
{/* 总数信息 */}
<div className="flex-1 text-sm text-muted-foreground">
{t('total', { count: total })}
</div>
{/* 分页控制 */}
<div className="flex items-center space-x-6 lg:space-x-8">
{/* 每页条数选择 */}
<div className="flex items-center space-x-2">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
{t('rowsPerPage')}
</Label>
<Select
value={`${pageSize}`}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="h-8 w-[90px]" id="rows-per-page">
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top">
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 页码信息 */}
<div className="flex items-center justify-center text-sm font-medium whitespace-nowrap">
{t('page', { current: page, total: totalPages || 1 })}
</div>
{/* 分页按钮 */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={handleFirstPage}
disabled={!canPreviousPage}
>
<span className="sr-only">{t('first')}</span>
<IconChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={handlePreviousPage}
disabled={!canPreviousPage}
>
<span className="sr-only">{t('previous')}</span>
<IconChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={handleNextPage}
disabled={!canNextPage}
>
<span className="sr-only">{t('next')}</span>
<IconChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={handleLastPage}
disabled={!canNextPage}
>
<span className="sr-only">{t('last')}</span>
<IconChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,256 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { ChevronDown, ChevronUp, Eye } from "lucide-react"
import { useTranslations } from "next-intl"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import type { SearchResult, Vulnerability } from "@/types/search.types"
interface SearchResultCardProps {
result: SearchResult
onViewVulnerability?: (vuln: Vulnerability) => void
}
// 漏洞严重程度颜色配置
const severityColors: Record<string, string> = {
critical: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]",
high: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20",
medium: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20",
low: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]",
info: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20",
}
export function SearchResultCard({ result, onViewVulnerability }: SearchResultCardProps) {
const t = useTranslations('search.card')
const [vulnOpen, setVulnOpen] = useState(false)
const [techExpanded, setTechExpanded] = useState(false)
const [isOverflowing, setIsOverflowing] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const formatHeaders = (headers: Record<string, string>) => {
return Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join("\n")
}
// 检测内容是否溢出
const maxHeight = 26 * 4 // 4行高度 (badge ~22px + gap 4px)
useEffect(() => {
const el = containerRef.current
if (!el || techExpanded) return
const checkOverflow = () => {
setIsOverflowing(el.scrollHeight > maxHeight)
}
checkOverflow()
const resizeObserver = new ResizeObserver(checkOverflow)
resizeObserver.observe(el)
return () => resizeObserver.disconnect()
}, [result.technologies, techExpanded, maxHeight])
const handleViewVulnerability = (vuln: Vulnerability) => {
if (onViewVulnerability) {
onViewVulnerability(vuln)
}
}
return (
<Card className="overflow-hidden py-0 gap-0">
<CardContent className="p-0">
{/* 顶部 URL 栏 */}
<h3 className="font-semibold text-sm px-4 py-2 bg-muted/30 border-b break-all">
{result.url || result.host}
</h3>
{/* 中间左右分栏 */}
<div className="flex flex-col md:flex-row">
{/* 左侧信息区 */}
<div className="w-full md:w-2/5 px-4 pt-2 pb-3 border-b md:border-b-0 md:border-r flex flex-col">
<div className="space-y-1.5 text-sm">
<div className="flex items-center h-[28px]">
<span className="text-muted-foreground w-12 shrink-0">{t('title')}</span>
<span className="font-medium truncate" title={result.title}>{result.title || '-'}</span>
</div>
<div className="flex items-center">
<span className="text-muted-foreground w-12 shrink-0">Host</span>
<span className="font-mono text-sm truncate" title={result.host}>{result.host || '-'}</span>
</div>
</div>
{/* Technologies 直接显示 */}
{result.technologies && result.technologies.length > 0 && (
<div className="mt-3 flex flex-col gap-1">
<div
ref={containerRef}
className="flex flex-wrap items-start gap-1 overflow-hidden transition-all duration-200"
style={{ maxHeight: techExpanded ? "none" : `${maxHeight}px` }}
>
{result.technologies.map((tech, index) => (
<Badge
key={`${tech}-${index}`}
variant="secondary"
className="text-xs"
>
{tech}
</Badge>
))}
</div>
{(isOverflowing || techExpanded) && (
<button
onClick={() => setTechExpanded(!techExpanded)}
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors self-start"
>
{techExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
<span>{t('collapse')}</span>
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
<span>{t('expand')}</span>
</>
)}
</button>
)}
</div>
)}
</div>
{/* 右侧 Tab 区 */}
<div className="w-full md:w-3/5 flex flex-col">
<Tabs defaultValue="header" className="w-full h-full flex flex-col gap-0">
<TabsList className="h-[28px] gap-4 rounded-none border-b bg-transparent px-4 pt-1">
<TabsTrigger
value="header"
className="h-full rounded-none border-b-2 border-transparent border-x-0 border-t-0 bg-transparent px-1 text-sm shadow-none focus-visible:ring-0 focus-visible:outline-none data-[state=active]:border-b-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
Header
</TabsTrigger>
<TabsTrigger
value="body"
className="h-full rounded-none border-b-2 border-transparent border-x-0 border-t-0 bg-transparent px-1 text-sm shadow-none focus-visible:ring-0 focus-visible:outline-none data-[state=active]:border-b-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
Body
</TabsTrigger>
</TabsList>
<TabsContent value="header" className="flex-1 overflow-auto bg-muted/30 px-4 py-2 max-h-[200px]">
<pre className="text-xs font-mono whitespace-pre-wrap">
{result.responseHeaders ? formatHeaders(result.responseHeaders) : '-'}
</pre>
</TabsContent>
<TabsContent value="body" className="flex-1 overflow-auto bg-muted/30 px-4 py-2 max-h-[200px]">
<pre className="text-xs font-mono whitespace-pre-wrap">
{result.responseBody || '-'}
</pre>
</TabsContent>
</Tabs>
</div>
</div>
{/* 底部漏洞区 */}
{result.vulnerabilities && result.vulnerabilities.length > 0 && (
<div className="border-t">
<Collapsible open={vulnOpen} onOpenChange={setVulnOpen}>
<CollapsibleTrigger className="flex items-center gap-1 px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full">
{vulnOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronUp className="size-4 rotate-90" />
)}
<span>{t('vulnerabilities', { count: result.vulnerabilities.length })}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-4 pb-4">
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="text-xs w-[50%]">{t('vulnName')}</TableHead>
<TableHead className="text-xs w-[20%]">{t('vulnType')}</TableHead>
<TableHead className="text-xs w-[20%]">{t('severity')}</TableHead>
<TableHead className="text-xs w-[10%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.vulnerabilities.map((vuln, index) => (
<TableRow key={`${vuln.name}-${index}`}>
<TableCell className="text-xs font-medium">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate block max-w-full cursor-default">
{vuln.name}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[400px]">
{vuln.name}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="text-xs">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate block max-w-full cursor-default">
{vuln.vulnType}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{vuln.vulnType}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`text-xs ${severityColors[vuln.severity] || severityColors.info}`}
>
{vuln.severity}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => handleViewVulnerability(vuln)}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { SearchService } from '@/services/search.service'
import type { SearchParams, SearchResponse } from '@/types/search.types'
/**
* 资产搜索 Hook
*
* @param params 搜索参数
* @param options 查询选项
* @returns 搜索结果
*/
export function useAssetSearch(
params: SearchParams,
options?: { enabled?: boolean }
) {
// 检查是否有有效的搜索查询
const hasSearchParams = !!(params.q && params.q.trim())
return useQuery<SearchResponse>({
queryKey: ['asset-search', params],
queryFn: () => SearchService.search(params),
enabled: (options?.enabled ?? true) && hasSearchParams,
placeholderData: keepPreviousData,
staleTime: 30000, // 30 秒内不重新请求
})
}

View File

@@ -296,6 +296,7 @@
"navigation": {
"mainFeatures": "Main Features",
"dashboard": "Dashboard",
"search": "Search",
"organization": "Organization",
"target": "Targets",
"vulnerabilities": "Vulnerabilities",
@@ -314,6 +315,43 @@
"help": "Get Help",
"feedback": "Feedback"
},
"search": {
"title": "Asset Search",
"hint": "Click search box to view available fields and syntax. Plain text defaults to hostname search",
"searching": "Searching...",
"loading": "Loading...",
"resultsCount": "Found {count} results",
"error": "Search failed, please try again later",
"noResults": "No matching assets found",
"noResultsHint": "Try adjusting your search criteria",
"vulnLoadError": "Failed to load vulnerability details",
"fields": {
"host": "Hostname",
"url": "URL address",
"title": "Page title",
"tech": "Technology stack",
"status": "HTTP status code",
"body": "Response body content",
"header": "Response header content"
},
"card": {
"title": "Title",
"expand": "Expand",
"collapse": "Collapse",
"vulnerabilities": "Vulnerabilities ({count})",
"vulnName": "Vulnerability Name",
"severity": "Severity",
"source": "Source",
"vulnType": "Vuln Type"
},
"vulnDetail": {
"title": "Vulnerability Details",
"name": "Vulnerability Name",
"source": "Source",
"type": "Vulnerability Type",
"url": "Vulnerability URL"
}
},
"dashboard": {
"title": "Dashboard",
"stats": {
@@ -715,6 +753,7 @@
"organizationMode": "Organization Scan",
"organizationModeHint": "In organization scan mode, all targets under this organization will be dynamically fetched at execution",
"noAvailableTarget": "No available targets",
"noEngine": "No engines available",
"selected": "Selected",
"selectedEngines": "{count} engines selected"
},

View File

@@ -296,6 +296,7 @@
"navigation": {
"mainFeatures": "主要功能",
"dashboard": "仪表盘",
"search": "搜索",
"organization": "组织",
"target": "目标",
"vulnerabilities": "漏洞",
@@ -314,6 +315,43 @@
"help": "获取帮助",
"feedback": "反馈建议"
},
"search": {
"title": "资产搜索",
"hint": "点击搜索框查看可用字段和语法,直接输入文本默认搜索主机名",
"searching": "搜索中...",
"loading": "加载中...",
"resultsCount": "找到 {count} 条结果",
"error": "搜索失败,请稍后重试",
"noResults": "未找到匹配的资产",
"noResultsHint": "请尝试调整搜索条件",
"vulnLoadError": "加载漏洞详情失败",
"fields": {
"host": "主机名",
"url": "URL 地址",
"title": "页面标题",
"tech": "技术栈",
"status": "HTTP 状态码",
"body": "响应体内容",
"header": "响应头内容"
},
"card": {
"title": "标题",
"expand": "展开",
"collapse": "收起",
"vulnerabilities": "关联漏洞 ({count})",
"vulnName": "漏洞名称",
"severity": "严重程度",
"source": "来源",
"vulnType": "漏洞类型"
},
"vulnDetail": {
"title": "漏洞详情",
"name": "漏洞名称",
"source": "来源",
"type": "漏洞类型",
"url": "漏洞 URL"
}
},
"dashboard": {
"title": "仪表盘",
"stats": {
@@ -715,6 +753,7 @@
"organizationMode": "组织扫描",
"organizationModeHint": "组织扫描模式下,执行时将动态获取该组织下所有目标",
"noAvailableTarget": "暂无可用目标",
"noEngine": "暂无可用引擎",
"selected": "已选择",
"selectedEngines": "已选择 {count} 个引擎"
},

23
frontend/mock/config.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Mock 数据配置
*
* 使用方式:
* 1. 在 .env.local 中设置 NEXT_PUBLIC_USE_MOCK=true 启用 mock 数据
* 2. 或者直接修改下面的 FORCE_MOCK 为 true
*/
// 强制使用 mock 数据(一般保持 false通过环境变量控制
const FORCE_MOCK = false
// 从环境变量读取 mock 配置
export const USE_MOCK = FORCE_MOCK || process.env.NEXT_PUBLIC_USE_MOCK === 'true'
// Mock 数据延迟(模拟网络请求)
export const MOCK_DELAY = 300 // ms
/**
* 模拟网络延迟
*/
export function mockDelay(ms: number = MOCK_DELAY): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

@@ -0,0 +1,71 @@
import type { AssetStatistics, StatisticsHistoryItem, DashboardStats } from '@/types/dashboard.types'
export const mockDashboardStats: DashboardStats = {
totalTargets: 156,
totalSubdomains: 4823,
totalEndpoints: 12456,
totalVulnerabilities: 89,
}
export const mockAssetStatistics: AssetStatistics = {
totalTargets: 156,
totalSubdomains: 4823,
totalIps: 892,
totalEndpoints: 12456,
totalWebsites: 3421,
totalVulns: 89,
totalAssets: 21638,
runningScans: 3,
updatedAt: new Date().toISOString(),
// 变化值
changeTargets: 12,
changeSubdomains: 234,
changeIps: 45,
changeEndpoints: 567,
changeWebsites: 89,
changeVulns: -5,
changeAssets: 942,
// 漏洞严重程度分布
vulnBySeverity: {
critical: 3,
high: 12,
medium: 28,
low: 34,
info: 12,
},
}
// 生成过去 N 天的历史数据
function generateHistoryData(days: number): StatisticsHistoryItem[] {
const data: StatisticsHistoryItem[] = []
const now = new Date()
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
// 模拟逐渐增长的趋势
const factor = 1 + (days - i) * 0.02
data.push({
date: date.toISOString().split('T')[0],
totalTargets: Math.floor(140 * factor),
totalSubdomains: Math.floor(4200 * factor),
totalIps: Math.floor(780 * factor),
totalEndpoints: Math.floor(10800 * factor),
totalWebsites: Math.floor(2980 * factor),
totalVulns: Math.floor(75 * factor),
totalAssets: Math.floor(18900 * factor),
})
}
return data
}
export const mockStatisticsHistory7Days = generateHistoryData(7)
export const mockStatisticsHistory30Days = generateHistoryData(30)
export function getMockStatisticsHistory(days: number): StatisticsHistoryItem[] {
if (days <= 7) return mockStatisticsHistory7Days
return generateHistoryData(days)
}

View File

@@ -0,0 +1,257 @@
import type { Endpoint, GetEndpointsResponse } from '@/types/endpoint.types'
export const mockEndpoints: Endpoint[] = [
{
id: 1,
url: 'https://acme.com/',
method: 'GET',
statusCode: 200,
title: 'Acme Corporation - Home',
contentLength: 45678,
contentType: 'text/html; charset=utf-8',
responseTime: 0.234,
host: 'acme.com',
webserver: 'nginx/1.24.0',
tech: ['React', 'Next.js', 'Node.js'],
createdAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
url: 'https://acme.com/login',
method: 'GET',
statusCode: 200,
title: 'Login - Acme',
contentLength: 12345,
contentType: 'text/html; charset=utf-8',
responseTime: 0.156,
host: 'acme.com',
webserver: 'nginx/1.24.0',
tech: ['React', 'Next.js'],
createdAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
url: 'https://api.acme.com/v1/users',
method: 'GET',
statusCode: 200,
title: '',
contentLength: 8923,
contentType: 'application/json',
responseTime: 0.089,
host: 'api.acme.com',
webserver: 'nginx/1.24.0',
tech: ['Django', 'Python'],
gfPatterns: ['api', 'json'],
createdAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
url: 'https://api.acme.com/v1/products',
method: 'GET',
statusCode: 200,
title: '',
contentLength: 23456,
contentType: 'application/json',
responseTime: 0.145,
host: 'api.acme.com',
webserver: 'nginx/1.24.0',
tech: ['Django', 'Python'],
gfPatterns: ['api', 'json'],
createdAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
url: 'https://acme.io/docs',
method: 'GET',
statusCode: 200,
title: 'Documentation - Acme.io',
contentLength: 67890,
contentType: 'text/html; charset=utf-8',
responseTime: 0.312,
host: 'acme.io',
webserver: 'cloudflare',
tech: ['Vue.js', 'Vitepress'],
createdAt: '2024-12-27T14:30:00Z',
},
{
id: 6,
url: 'https://acme.io/api/config',
method: 'GET',
statusCode: 200,
title: '',
contentLength: 1234,
contentType: 'application/json',
responseTime: 0.067,
host: 'acme.io',
webserver: 'cloudflare',
tech: ['Node.js', 'Express'],
gfPatterns: ['config', 'json'],
createdAt: '2024-12-27T14:31:00Z',
},
{
id: 7,
url: 'https://techstart.io/',
method: 'GET',
statusCode: 200,
title: 'TechStart - Innovation Hub',
contentLength: 34567,
contentType: 'text/html; charset=utf-8',
responseTime: 0.278,
host: 'techstart.io',
webserver: 'Apache/2.4.54',
tech: ['WordPress', 'PHP'],
createdAt: '2024-12-26T08:45:00Z',
},
{
id: 8,
url: 'https://techstart.io/admin',
method: 'GET',
statusCode: 302,
title: '',
contentLength: 0,
contentType: 'text/html',
responseTime: 0.045,
location: 'https://techstart.io/admin/login',
host: 'techstart.io',
webserver: 'Apache/2.4.54',
tech: ['WordPress', 'PHP'],
createdAt: '2024-12-26T08:46:00Z',
},
{
id: 9,
url: 'https://globalfinance.com/',
method: 'GET',
statusCode: 200,
title: 'Global Finance - Your Financial Partner',
contentLength: 56789,
contentType: 'text/html; charset=utf-8',
responseTime: 0.456,
host: 'globalfinance.com',
webserver: 'Microsoft-IIS/10.0',
tech: ['ASP.NET', 'C#', 'jQuery'],
createdAt: '2024-12-25T16:20:00Z',
},
{
id: 10,
url: 'https://globalfinance.com/.git/config',
method: 'GET',
statusCode: 200,
title: '',
contentLength: 456,
contentType: 'text/plain',
responseTime: 0.034,
host: 'globalfinance.com',
webserver: 'Microsoft-IIS/10.0',
gfPatterns: ['git', 'config'],
createdAt: '2024-12-25T16:21:00Z',
},
{
id: 11,
url: 'https://retailmax.com/',
method: 'GET',
statusCode: 200,
title: 'RetailMax - Shop Everything',
contentLength: 89012,
contentType: 'text/html; charset=utf-8',
responseTime: 0.567,
host: 'retailmax.com',
webserver: 'nginx/1.22.0',
tech: ['React', 'Redux', 'Node.js'],
createdAt: '2024-12-21T10:45:00Z',
},
{
id: 12,
url: 'https://retailmax.com/product?id=1',
method: 'GET',
statusCode: 200,
title: 'Product Detail - RetailMax',
contentLength: 23456,
contentType: 'text/html; charset=utf-8',
responseTime: 0.234,
host: 'retailmax.com',
webserver: 'nginx/1.22.0',
tech: ['React', 'Redux'],
gfPatterns: ['param', 'id'],
createdAt: '2024-12-21T10:46:00Z',
},
{
id: 13,
url: 'https://healthcareplus.com/',
method: 'GET',
statusCode: 200,
title: 'HealthCare Plus - Digital Health',
contentLength: 45678,
contentType: 'text/html; charset=utf-8',
responseTime: 0.345,
host: 'healthcareplus.com',
webserver: 'nginx/1.24.0',
tech: ['Angular', 'TypeScript'],
createdAt: '2024-12-23T11:00:00Z',
},
{
id: 14,
url: 'https://edutech.io/',
method: 'GET',
statusCode: 200,
title: 'EduTech - Learn Anywhere',
contentLength: 67890,
contentType: 'text/html; charset=utf-8',
responseTime: 0.289,
host: 'edutech.io',
webserver: 'cloudflare',
tech: ['Vue.js', 'Nuxt.js'],
createdAt: '2024-12-22T13:30:00Z',
},
{
id: 15,
url: 'https://cloudnine.host/',
method: 'GET',
statusCode: 200,
title: 'CloudNine Hosting',
contentLength: 34567,
contentType: 'text/html; charset=utf-8',
responseTime: 0.178,
host: 'cloudnine.host',
webserver: 'LiteSpeed',
tech: ['PHP', 'Laravel'],
createdAt: '2024-12-19T16:00:00Z',
},
]
export function getMockEndpoints(params?: {
page?: number
pageSize?: number
search?: string
}): GetEndpointsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
let filtered = mockEndpoints
if (search) {
filtered = mockEndpoints.filter(
ep =>
ep.url.toLowerCase().includes(search) ||
ep.title.toLowerCase().includes(search) ||
ep.host?.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const endpoints = filtered.slice(start, start + pageSize)
return {
endpoints,
total,
page,
pageSize,
totalPages,
}
}
export function getMockEndpointById(id: number): Endpoint | undefined {
return mockEndpoints.find(ep => ep.id === id)
}

View File

@@ -0,0 +1,145 @@
import type { Organization, OrganizationsResponse } from '@/types/organization.types'
export const mockOrganizations: Organization[] = [
{
id: 1,
name: 'Acme Corporation',
description: '全球领先的科技公司,专注于云计算和人工智能领域',
createdAt: '2024-01-15T08:30:00Z',
updatedAt: '2024-12-28T14:20:00Z',
targetCount: 12,
domainCount: 156,
endpointCount: 2341,
targets: [
{ id: 1, name: 'acme.com' },
{ id: 2, name: 'acme.io' },
],
},
{
id: 2,
name: 'TechStart Inc',
description: '创新型初创企业,主营 SaaS 产品开发',
createdAt: '2024-02-20T10:15:00Z',
updatedAt: '2024-12-27T09:45:00Z',
targetCount: 5,
domainCount: 78,
endpointCount: 892,
targets: [
{ id: 3, name: 'techstart.io' },
],
},
{
id: 3,
name: 'Global Finance Ltd',
description: '国际金融服务公司,提供银行和投资解决方案',
createdAt: '2024-03-10T14:00:00Z',
updatedAt: '2024-12-26T16:30:00Z',
targetCount: 8,
domainCount: 234,
endpointCount: 1567,
targets: [
{ id: 4, name: 'globalfinance.com' },
{ id: 5, name: 'gf-bank.net' },
],
},
{
id: 4,
name: 'HealthCare Plus',
description: '医疗健康科技公司,专注于数字化医疗解决方案',
createdAt: '2024-04-05T09:20:00Z',
updatedAt: '2024-12-25T11:10:00Z',
targetCount: 6,
domainCount: 89,
endpointCount: 723,
targets: [
{ id: 6, name: 'healthcareplus.com' },
],
},
{
id: 5,
name: 'EduTech Solutions',
description: '在线教育平台,提供 K-12 和职业培训课程',
createdAt: '2024-05-12T11:45:00Z',
updatedAt: '2024-12-24T13:55:00Z',
targetCount: 4,
domainCount: 45,
endpointCount: 456,
targets: [
{ id: 7, name: 'edutech.io' },
],
},
{
id: 6,
name: 'RetailMax',
description: '电子商务零售平台,覆盖多品类商品销售',
createdAt: '2024-06-08T16:30:00Z',
updatedAt: '2024-12-23T10:20:00Z',
targetCount: 15,
domainCount: 312,
endpointCount: 4521,
targets: [
{ id: 8, name: 'retailmax.com' },
{ id: 9, name: 'retailmax.cn' },
],
},
{
id: 7,
name: 'CloudNine Hosting',
description: '云托管服务提供商,提供 VPS 和专用服务器',
createdAt: '2024-07-20T08:00:00Z',
updatedAt: '2024-12-22T15:40:00Z',
targetCount: 3,
domainCount: 67,
endpointCount: 389,
targets: [
{ id: 10, name: 'cloudnine.host' },
],
},
{
id: 8,
name: 'MediaStream Corp',
description: '流媒体内容分发平台,提供视频和音频服务',
createdAt: '2024-08-15T12:10:00Z',
updatedAt: '2024-12-21T08:25:00Z',
targetCount: 7,
domainCount: 123,
endpointCount: 1234,
targets: [
{ id: 11, name: 'mediastream.tv' },
],
},
]
export function getMockOrganizations(params?: {
page?: number
pageSize?: number
search?: string
}): OrganizationsResponse<Organization> {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
// 过滤搜索
let filtered = mockOrganizations
if (search) {
filtered = mockOrganizations.filter(
org =>
org.name.toLowerCase().includes(search) ||
org.description.toLowerCase().includes(search)
)
}
// 分页
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,
}
}

309
frontend/mock/data/scans.ts Normal file
View File

@@ -0,0 +1,309 @@
import type { ScanRecord, GetScansResponse, ScanStatus } from '@/types/scan.types'
import type { ScanStatistics } from '@/services/scan.service'
export const mockScans: ScanRecord[] = [
{
id: 1,
target: 1,
targetName: 'acme.com',
workerName: 'worker-01',
summary: {
subdomains: 156,
websites: 89,
directories: 234,
endpoints: 2341,
ips: 45,
vulnerabilities: {
total: 23,
critical: 1,
high: 4,
medium: 8,
low: 10,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-28T10:00:00Z',
status: 'completed',
progress: 100,
},
{
id: 2,
target: 2,
targetName: 'acme.io',
workerName: 'worker-02',
summary: {
subdomains: 78,
websites: 45,
directories: 123,
endpoints: 892,
ips: 23,
vulnerabilities: {
total: 12,
critical: 0,
high: 2,
medium: 5,
low: 5,
},
},
engineIds: [1, 2],
engineNames: ['Subdomain Discovery', 'Web Crawling'],
createdAt: '2024-12-27T14:30:00Z',
status: 'running',
progress: 65,
currentStage: 'web_crawling',
stageProgress: {
subdomain_discovery: {
status: 'completed',
order: 0,
startedAt: '2024-12-27T14:30:00Z',
duration: 1200,
detail: 'Found 78 subdomains',
},
web_crawling: {
status: 'running',
order: 1,
startedAt: '2024-12-27T14:50:00Z',
},
},
},
{
id: 3,
target: 3,
targetName: 'techstart.io',
workerName: 'worker-01',
summary: {
subdomains: 45,
websites: 28,
directories: 89,
endpoints: 567,
ips: 12,
vulnerabilities: {
total: 8,
critical: 0,
high: 1,
medium: 3,
low: 4,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-26T08:45:00Z',
status: 'completed',
progress: 100,
},
{
id: 4,
target: 4,
targetName: 'globalfinance.com',
workerName: 'worker-03',
summary: {
subdomains: 0,
websites: 0,
directories: 0,
endpoints: 0,
ips: 0,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
},
engineIds: [1],
engineNames: ['Subdomain Discovery'],
createdAt: '2024-12-25T16:20:00Z',
status: 'failed',
progress: 15,
errorMessage: 'Connection timeout: Unable to reach target',
},
{
id: 5,
target: 6,
targetName: 'healthcareplus.com',
workerName: 'worker-02',
summary: {
subdomains: 34,
websites: 0,
directories: 0,
endpoints: 0,
ips: 8,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-29T09:00:00Z',
status: 'running',
progress: 25,
currentStage: 'subdomain_discovery',
stageProgress: {
subdomain_discovery: {
status: 'running',
order: 0,
startedAt: '2024-12-29T09:00:00Z',
},
web_crawling: {
status: 'pending',
order: 1,
},
nuclei_scan: {
status: 'pending',
order: 2,
},
},
},
{
id: 6,
target: 7,
targetName: 'edutech.io',
workerName: null,
summary: {
subdomains: 0,
websites: 0,
directories: 0,
endpoints: 0,
ips: 0,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
},
engineIds: [1, 2],
engineNames: ['Subdomain Discovery', 'Web Crawling'],
createdAt: '2024-12-29T10:30:00Z',
status: 'initiated',
progress: 0,
},
{
id: 7,
target: 8,
targetName: 'retailmax.com',
workerName: 'worker-01',
summary: {
subdomains: 89,
websites: 56,
directories: 178,
endpoints: 1234,
ips: 28,
vulnerabilities: {
total: 15,
critical: 0,
high: 3,
medium: 6,
low: 6,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-21T10:45:00Z',
status: 'completed',
progress: 100,
},
{
id: 8,
target: 11,
targetName: 'mediastream.tv',
workerName: 'worker-02',
summary: {
subdomains: 67,
websites: 0,
directories: 0,
endpoints: 0,
ips: 15,
vulnerabilities: {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
},
},
engineIds: [1, 2, 3],
engineNames: ['Subdomain Discovery', 'Web Crawling', 'Nuclei Scanner'],
createdAt: '2024-12-29T08:00:00Z',
status: 'running',
progress: 45,
currentStage: 'web_crawling',
stageProgress: {
subdomain_discovery: {
status: 'completed',
order: 0,
startedAt: '2024-12-29T08:00:00Z',
duration: 900,
detail: 'Found 67 subdomains',
},
web_crawling: {
status: 'running',
order: 1,
startedAt: '2024-12-29T08:15:00Z',
},
nuclei_scan: {
status: 'pending',
order: 2,
},
},
},
]
export const mockScanStatistics: ScanStatistics = {
total: 156,
running: 3,
completed: 142,
failed: 11,
totalVulns: 89,
totalSubdomains: 4823,
totalEndpoints: 12456,
totalWebsites: 3421,
totalAssets: 21638,
}
export function getMockScans(params?: {
page?: number
pageSize?: number
status?: ScanStatus
search?: string
}): GetScansResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const status = params?.status
const search = params?.search?.toLowerCase() || ''
let filtered = mockScans
if (status) {
filtered = filtered.filter(scan => scan.status === status)
}
if (search) {
filtered = filtered.filter(scan =>
scan.targetName.toLowerCase().includes(search)
)
}
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 getMockScanById(id: number): ScanRecord | undefined {
return mockScans.find(scan => scan.id === id)
}

View File

@@ -0,0 +1,78 @@
import type { Subdomain, GetAllSubdomainsResponse } from '@/types/subdomain.types'
export const mockSubdomains: Subdomain[] = [
{ id: 1, name: 'acme.com', createdAt: '2024-12-28T10:00:00Z' },
{ id: 2, name: 'www.acme.com', createdAt: '2024-12-28T10:01:00Z' },
{ id: 3, name: 'api.acme.com', createdAt: '2024-12-28T10:02:00Z' },
{ id: 4, name: 'admin.acme.com', createdAt: '2024-12-28T10:03:00Z' },
{ id: 5, name: 'mail.acme.com', createdAt: '2024-12-28T10:04:00Z' },
{ id: 6, name: 'blog.acme.com', createdAt: '2024-12-28T10:05:00Z' },
{ id: 7, name: 'shop.acme.com', createdAt: '2024-12-28T10:06:00Z' },
{ id: 8, name: 'cdn.acme.com', createdAt: '2024-12-28T10:07:00Z' },
{ id: 9, name: 'static.acme.com', createdAt: '2024-12-28T10:08:00Z' },
{ id: 10, name: 'dev.acme.com', createdAt: '2024-12-28T10:09:00Z' },
{ id: 11, name: 'staging.acme.com', createdAt: '2024-12-28T10:10:00Z' },
{ id: 12, name: 'test.acme.com', createdAt: '2024-12-28T10:11:00Z' },
{ id: 13, name: 'acme.io', createdAt: '2024-12-27T14:30:00Z' },
{ id: 14, name: 'docs.acme.io', createdAt: '2024-12-27T14:31:00Z' },
{ id: 15, name: 'api.acme.io', createdAt: '2024-12-27T14:32:00Z' },
{ id: 16, name: 'status.acme.io', createdAt: '2024-12-27T14:33:00Z' },
{ id: 17, name: 'techstart.io', createdAt: '2024-12-26T08:45:00Z' },
{ id: 18, name: 'www.techstart.io', createdAt: '2024-12-26T08:46:00Z' },
{ id: 19, name: 'app.techstart.io', createdAt: '2024-12-26T08:47:00Z' },
{ id: 20, name: 'globalfinance.com', createdAt: '2024-12-25T16:20:00Z' },
{ id: 21, name: 'www.globalfinance.com', createdAt: '2024-12-25T16:21:00Z' },
{ id: 22, name: 'secure.globalfinance.com', createdAt: '2024-12-25T16:22:00Z' },
{ id: 23, name: 'portal.globalfinance.com', createdAt: '2024-12-25T16:23:00Z' },
{ id: 24, name: 'healthcareplus.com', createdAt: '2024-12-23T11:00:00Z' },
{ id: 25, name: 'www.healthcareplus.com', createdAt: '2024-12-23T11:01:00Z' },
{ id: 26, name: 'patient.healthcareplus.com', createdAt: '2024-12-23T11:02:00Z' },
{ id: 27, name: 'edutech.io', createdAt: '2024-12-22T13:30:00Z' },
{ id: 28, name: 'learn.edutech.io', createdAt: '2024-12-22T13:31:00Z' },
{ id: 29, name: 'retailmax.com', createdAt: '2024-12-21T10:45:00Z' },
{ id: 30, name: 'www.retailmax.com', createdAt: '2024-12-21T10:46:00Z' },
{ id: 31, name: 'm.retailmax.com', createdAt: '2024-12-21T10:47:00Z' },
{ id: 32, name: 'api.retailmax.com', createdAt: '2024-12-21T10:48:00Z' },
{ id: 33, name: 'cloudnine.host', createdAt: '2024-12-19T16:00:00Z' },
{ id: 34, name: 'panel.cloudnine.host', createdAt: '2024-12-19T16:01:00Z' },
{ id: 35, name: 'mediastream.tv', createdAt: '2024-12-18T09:30:00Z' },
{ id: 36, name: 'www.mediastream.tv', createdAt: '2024-12-18T09:31:00Z' },
{ id: 37, name: 'cdn.mediastream.tv', createdAt: '2024-12-18T09:32:00Z' },
{ id: 38, name: 'stream.mediastream.tv', createdAt: '2024-12-18T09:33:00Z' },
]
export function getMockSubdomains(params?: {
page?: number
pageSize?: number
search?: string
organizationId?: number
}): GetAllSubdomainsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
let filtered = mockSubdomains
if (search) {
filtered = mockSubdomains.filter(sub =>
sub.name.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const domains = filtered.slice(start, start + pageSize)
return {
domains,
total,
page,
pageSize,
totalPages,
}
}
export function getMockSubdomainById(id: number): Subdomain | undefined {
return mockSubdomains.find(sub => sub.id === id)
}

View File

@@ -0,0 +1,205 @@
import type { Target, TargetsResponse, TargetDetail } from '@/types/target.types'
export const mockTargets: Target[] = [
{
id: 1,
name: 'acme.com',
type: 'domain',
description: 'Acme Corporation 主站',
createdAt: '2024-01-15T08:30:00Z',
lastScannedAt: '2024-12-28T10:00:00Z',
organizations: [{ id: 1, name: 'Acme Corporation' }],
},
{
id: 2,
name: 'acme.io',
type: 'domain',
description: 'Acme Corporation 开发者平台',
createdAt: '2024-01-16T09:00:00Z',
lastScannedAt: '2024-12-27T14:30:00Z',
organizations: [{ id: 1, name: 'Acme Corporation' }],
},
{
id: 3,
name: 'techstart.io',
type: 'domain',
description: 'TechStart 官网',
createdAt: '2024-02-20T10:15:00Z',
lastScannedAt: '2024-12-26T08:45:00Z',
organizations: [{ id: 2, name: 'TechStart Inc' }],
},
{
id: 4,
name: 'globalfinance.com',
type: 'domain',
description: 'Global Finance 主站',
createdAt: '2024-03-10T14:00:00Z',
lastScannedAt: '2024-12-25T16:20:00Z',
organizations: [{ id: 3, name: 'Global Finance Ltd' }],
},
{
id: 5,
name: '192.168.1.0/24',
type: 'cidr',
description: '内网 IP 段',
createdAt: '2024-03-15T11:30:00Z',
lastScannedAt: '2024-12-24T09:15:00Z',
organizations: [{ id: 3, name: 'Global Finance Ltd' }],
},
{
id: 6,
name: 'healthcareplus.com',
type: 'domain',
description: 'HealthCare Plus 官网',
createdAt: '2024-04-05T09:20:00Z',
lastScannedAt: '2024-12-23T11:00:00Z',
organizations: [{ id: 4, name: 'HealthCare Plus' }],
},
{
id: 7,
name: 'edutech.io',
type: 'domain',
description: 'EduTech 在线教育平台',
createdAt: '2024-05-12T11:45:00Z',
lastScannedAt: '2024-12-22T13:30:00Z',
organizations: [{ id: 5, name: 'EduTech Solutions' }],
},
{
id: 8,
name: 'retailmax.com',
type: 'domain',
description: 'RetailMax 电商主站',
createdAt: '2024-06-08T16:30:00Z',
lastScannedAt: '2024-12-21T10:45:00Z',
organizations: [{ id: 6, name: 'RetailMax' }],
},
{
id: 9,
name: '10.0.0.1',
type: 'ip',
description: '核心服务器 IP',
createdAt: '2024-07-01T08:00:00Z',
lastScannedAt: '2024-12-20T14:20:00Z',
organizations: [{ id: 7, name: 'CloudNine Hosting' }],
},
{
id: 10,
name: 'cloudnine.host',
type: 'domain',
description: 'CloudNine 托管服务',
createdAt: '2024-07-20T08:00:00Z',
lastScannedAt: '2024-12-19T16:00:00Z',
organizations: [{ id: 7, name: 'CloudNine Hosting' }],
},
{
id: 11,
name: 'mediastream.tv',
type: 'domain',
description: 'MediaStream 流媒体平台',
createdAt: '2024-08-15T12:10:00Z',
lastScannedAt: '2024-12-18T09:30:00Z',
organizations: [{ id: 8, name: 'MediaStream Corp' }],
},
{
id: 12,
name: 'api.acme.com',
type: 'domain',
description: 'Acme API 服务',
createdAt: '2024-09-01T10:00:00Z',
lastScannedAt: '2024-12-17T11:15:00Z',
organizations: [{ id: 1, name: 'Acme Corporation' }],
},
]
export const mockTargetDetails: Record<number, TargetDetail> = {
1: {
...mockTargets[0],
summary: {
subdomains: 156,
websites: 89,
endpoints: 2341,
ips: 45,
vulnerabilities: {
total: 23,
critical: 1,
high: 4,
medium: 8,
low: 10,
},
},
},
2: {
...mockTargets[1],
summary: {
subdomains: 78,
websites: 45,
endpoints: 892,
ips: 23,
vulnerabilities: {
total: 12,
critical: 0,
high: 2,
medium: 5,
low: 5,
},
},
},
}
export function getMockTargets(params?: {
page?: number
pageSize?: number
search?: string
}): TargetsResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
let filtered = mockTargets
if (search) {
filtered = mockTargets.filter(
target =>
target.name.toLowerCase().includes(search) ||
target.description?.toLowerCase().includes(search)
)
}
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 getMockTargetById(id: number): TargetDetail | undefined {
if (mockTargetDetails[id]) {
return mockTargetDetails[id]
}
const target = mockTargets.find(t => t.id === id)
if (target) {
return {
...target,
summary: {
subdomains: Math.floor(Math.random() * 100) + 10,
websites: Math.floor(Math.random() * 50) + 5,
endpoints: Math.floor(Math.random() * 1000) + 100,
ips: Math.floor(Math.random() * 30) + 5,
vulnerabilities: {
total: Math.floor(Math.random() * 20) + 1,
critical: Math.floor(Math.random() * 2),
high: Math.floor(Math.random() * 5),
medium: Math.floor(Math.random() * 8),
low: Math.floor(Math.random() * 10),
},
},
}
}
return undefined
}

View File

@@ -0,0 +1,275 @@
import type { Vulnerability, GetVulnerabilitiesResponse, VulnerabilitySeverity } from '@/types/vulnerability.types'
export const mockVulnerabilities: Vulnerability[] = [
{
id: 1,
target: 1,
url: 'https://acme.com/search?q=test',
vulnType: 'xss-reflected',
severity: 'critical',
source: 'dalfox',
cvssScore: 9.1,
description: 'Reflected XSS in search parameter',
rawOutput: {
type: 'R',
inject_type: 'inHTML-URL',
method: 'GET',
data: 'https://acme.com/search?q=<script>alert(1)</script>',
param: 'q',
payload: '<script>alert(1)</script>',
evidence: '<script>alert(1)</script>',
cwe: 'CWE-79',
},
createdAt: '2024-12-28T10:30:00Z',
},
{
id: 2,
target: 1,
url: 'https://api.acme.com/v1/users',
vulnType: 'CVE-2024-1234',
severity: 'high',
source: 'nuclei',
cvssScore: 8.5,
description: 'SQL Injection in user API endpoint',
rawOutput: {
'template-id': 'CVE-2024-1234',
'matched-at': 'https://api.acme.com/v1/users',
host: 'api.acme.com',
info: {
name: 'SQL Injection',
description: 'SQL injection vulnerability in user endpoint',
severity: 'high',
tags: ['sqli', 'cve'],
reference: ['https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-1234'],
classification: {
'cve-id': 'CVE-2024-1234',
'cwe-id': ['CWE-89'],
},
},
},
createdAt: '2024-12-28T10:45:00Z',
},
{
id: 3,
target: 1,
url: 'https://acme.com/login',
vulnType: 'xss-stored',
severity: 'high',
source: 'dalfox',
cvssScore: 8.2,
description: 'Stored XSS in user profile',
rawOutput: {
type: 'S',
inject_type: 'inHTML-TAG',
method: 'POST',
param: 'bio',
payload: '<img src=x onerror=alert(1)>',
},
createdAt: '2024-12-27T14:20:00Z',
},
{
id: 4,
target: 2,
url: 'https://acme.io/api/config',
vulnType: 'information-disclosure',
severity: 'medium',
source: 'nuclei',
cvssScore: 5.3,
description: 'Exposed configuration file',
rawOutput: {
'template-id': 'exposed-config',
'matched-at': 'https://acme.io/api/config',
host: 'acme.io',
info: {
name: 'Exposed Configuration',
description: 'Configuration file accessible without authentication',
severity: 'medium',
tags: ['exposure', 'config'],
},
},
createdAt: '2024-12-27T15:00:00Z',
},
{
id: 5,
target: 3,
url: 'https://techstart.io/admin',
vulnType: 'open-redirect',
severity: 'medium',
source: 'nuclei',
cvssScore: 4.7,
description: 'Open redirect vulnerability',
rawOutput: {
'template-id': 'open-redirect',
'matched-at': 'https://techstart.io/admin?redirect=evil.com',
host: 'techstart.io',
info: {
name: 'Open Redirect',
description: 'URL redirect without validation',
severity: 'medium',
tags: ['redirect'],
},
},
createdAt: '2024-12-26T09:30:00Z',
},
{
id: 6,
target: 4,
url: 'https://globalfinance.com/.git/config',
vulnType: 'git-config-exposure',
severity: 'high',
source: 'nuclei',
cvssScore: 7.5,
description: 'Git configuration file exposed',
rawOutput: {
'template-id': 'git-config',
'matched-at': 'https://globalfinance.com/.git/config',
host: 'globalfinance.com',
info: {
name: 'Git Config Exposure',
description: 'Git configuration file is publicly accessible',
severity: 'high',
tags: ['git', 'exposure'],
},
},
createdAt: '2024-12-25T11:15:00Z',
},
{
id: 7,
target: 8,
url: 'https://retailmax.com/product?id=1',
vulnType: 'sqli',
severity: 'critical',
source: 'nuclei',
cvssScore: 9.8,
description: 'SQL Injection in product parameter',
rawOutput: {
'template-id': 'generic-sqli',
'matched-at': "https://retailmax.com/product?id=1'",
host: 'retailmax.com',
info: {
name: 'SQL Injection',
description: 'SQL injection in product ID parameter',
severity: 'critical',
tags: ['sqli'],
classification: {
'cwe-id': ['CWE-89'],
},
},
},
createdAt: '2024-12-21T12:00:00Z',
},
{
id: 8,
target: 1,
url: 'https://acme.com/robots.txt',
vulnType: 'robots-txt-exposure',
severity: 'info',
source: 'nuclei',
description: 'Robots.txt file found',
rawOutput: {
'template-id': 'robots-txt',
'matched-at': 'https://acme.com/robots.txt',
host: 'acme.com',
info: {
name: 'Robots.txt',
description: 'Robots.txt file detected',
severity: 'info',
tags: ['misc'],
},
},
createdAt: '2024-12-28T10:00:00Z',
},
{
id: 9,
target: 2,
url: 'https://acme.io/sitemap.xml',
vulnType: 'sitemap-exposure',
severity: 'info',
source: 'nuclei',
description: 'Sitemap.xml file found',
rawOutput: {
'template-id': 'sitemap-xml',
'matched-at': 'https://acme.io/sitemap.xml',
host: 'acme.io',
info: {
name: 'Sitemap.xml',
description: 'Sitemap.xml file detected',
severity: 'info',
tags: ['misc'],
},
},
createdAt: '2024-12-27T14:00:00Z',
},
{
id: 10,
target: 3,
url: 'https://techstart.io/api/v2/debug',
vulnType: 'debug-endpoint',
severity: 'low',
source: 'nuclei',
cvssScore: 3.1,
description: 'Debug endpoint exposed',
rawOutput: {
'template-id': 'debug-endpoint',
'matched-at': 'https://techstart.io/api/v2/debug',
host: 'techstart.io',
info: {
name: 'Debug Endpoint',
description: 'Debug endpoint accessible in production',
severity: 'low',
tags: ['debug', 'exposure'],
},
},
createdAt: '2024-12-26T10:00:00Z',
},
]
export function getMockVulnerabilities(params?: {
page?: number
pageSize?: number
targetId?: number
severity?: VulnerabilitySeverity
search?: string
}): GetVulnerabilitiesResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const targetId = params?.targetId
const severity = params?.severity
const search = params?.search?.toLowerCase() || ''
let filtered = mockVulnerabilities
if (targetId) {
filtered = filtered.filter(v => v.target === targetId)
}
if (severity) {
filtered = filtered.filter(v => v.severity === severity)
}
if (search) {
filtered = filtered.filter(
v =>
v.url.toLowerCase().includes(search) ||
v.vulnType.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
)
}
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const vulnerabilities = filtered.slice(start, start + pageSize)
return {
vulnerabilities,
total,
page,
pageSize,
totalPages,
}
}
export function getMockVulnerabilityById(id: number): Vulnerability | undefined {
return mockVulnerabilities.find(v => v.id === id)
}

View File

@@ -0,0 +1,252 @@
import type { WebSite, WebSiteListResponse } from '@/types/website.types'
export const mockWebsites: WebSite[] = [
{
id: 1,
target: 1,
url: 'https://acme.com',
host: 'acme.com',
location: '',
title: 'Acme Corporation - Home',
webserver: 'nginx/1.24.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 45678,
responseBody: '<!DOCTYPE html>...',
tech: ['React', 'Next.js', 'Node.js', 'Tailwind CSS'],
vhost: false,
subdomain: 'acme.com',
createdAt: '2024-12-28T10:00:00Z',
},
{
id: 2,
target: 1,
url: 'https://www.acme.com',
host: 'www.acme.com',
location: 'https://acme.com',
title: 'Acme Corporation - Home',
webserver: 'nginx/1.24.0',
contentType: 'text/html; charset=utf-8',
statusCode: 301,
contentLength: 0,
responseBody: '',
tech: [],
vhost: false,
subdomain: 'www.acme.com',
createdAt: '2024-12-28T10:01:00Z',
},
{
id: 3,
target: 1,
url: 'https://api.acme.com',
host: 'api.acme.com',
location: '',
title: 'Acme API',
webserver: 'nginx/1.24.0',
contentType: 'application/json',
statusCode: 200,
contentLength: 234,
responseBody: '{"status":"ok","version":"1.0"}',
tech: ['Django', 'Python', 'PostgreSQL'],
vhost: false,
subdomain: 'api.acme.com',
createdAt: '2024-12-28T10:02:00Z',
},
{
id: 4,
target: 1,
url: 'https://admin.acme.com',
host: 'admin.acme.com',
location: '',
title: 'Admin Panel - Acme',
webserver: 'nginx/1.24.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 23456,
responseBody: '<!DOCTYPE html>...',
tech: ['React', 'Ant Design'],
vhost: false,
subdomain: 'admin.acme.com',
createdAt: '2024-12-28T10:03:00Z',
},
{
id: 5,
target: 2,
url: 'https://acme.io',
host: 'acme.io',
location: '',
title: 'Acme Developer Platform',
webserver: 'cloudflare',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 56789,
responseBody: '<!DOCTYPE html>...',
tech: ['Vue.js', 'Vitepress', 'CloudFlare'],
vhost: false,
subdomain: 'acme.io',
createdAt: '2024-12-27T14:30:00Z',
},
{
id: 6,
target: 2,
url: 'https://docs.acme.io',
host: 'docs.acme.io',
location: '',
title: 'Documentation - Acme.io',
webserver: 'cloudflare',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 67890,
responseBody: '<!DOCTYPE html>...',
tech: ['Vue.js', 'Vitepress'],
vhost: false,
subdomain: 'docs.acme.io',
createdAt: '2024-12-27T14:31:00Z',
},
{
id: 7,
target: 3,
url: 'https://techstart.io',
host: 'techstart.io',
location: '',
title: 'TechStart - Innovation Hub',
webserver: 'Apache/2.4.54',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 34567,
responseBody: '<!DOCTYPE html>...',
tech: ['WordPress', 'PHP', 'MySQL'],
vhost: false,
subdomain: 'techstart.io',
createdAt: '2024-12-26T08:45:00Z',
},
{
id: 8,
target: 4,
url: 'https://globalfinance.com',
host: 'globalfinance.com',
location: '',
title: 'Global Finance - Your Financial Partner',
webserver: 'Microsoft-IIS/10.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 56789,
responseBody: '<!DOCTYPE html>...',
tech: ['ASP.NET', 'C#', 'jQuery', 'SQL Server'],
vhost: false,
subdomain: 'globalfinance.com',
createdAt: '2024-12-25T16:20:00Z',
},
{
id: 9,
target: 6,
url: 'https://healthcareplus.com',
host: 'healthcareplus.com',
location: '',
title: 'HealthCare Plus - Digital Health',
webserver: 'nginx/1.24.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 45678,
responseBody: '<!DOCTYPE html>...',
tech: ['Angular', 'TypeScript', 'Node.js'],
vhost: false,
subdomain: 'healthcareplus.com',
createdAt: '2024-12-23T11:00:00Z',
},
{
id: 10,
target: 7,
url: 'https://edutech.io',
host: 'edutech.io',
location: '',
title: 'EduTech - Learn Anywhere',
webserver: 'cloudflare',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 67890,
responseBody: '<!DOCTYPE html>...',
tech: ['Vue.js', 'Nuxt.js', 'PostgreSQL'],
vhost: false,
subdomain: 'edutech.io',
createdAt: '2024-12-22T13:30:00Z',
},
{
id: 11,
target: 8,
url: 'https://retailmax.com',
host: 'retailmax.com',
location: '',
title: 'RetailMax - Shop Everything',
webserver: 'nginx/1.22.0',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 89012,
responseBody: '<!DOCTYPE html>...',
tech: ['React', 'Redux', 'Node.js', 'MongoDB'],
vhost: false,
subdomain: 'retailmax.com',
createdAt: '2024-12-21T10:45:00Z',
},
{
id: 12,
target: 10,
url: 'https://cloudnine.host',
host: 'cloudnine.host',
location: '',
title: 'CloudNine Hosting',
webserver: 'LiteSpeed',
contentType: 'text/html; charset=utf-8',
statusCode: 200,
contentLength: 34567,
responseBody: '<!DOCTYPE html>...',
tech: ['PHP', 'Laravel', 'MySQL'],
vhost: false,
subdomain: 'cloudnine.host',
createdAt: '2024-12-19T16:00:00Z',
},
]
export function getMockWebsites(params?: {
page?: number
pageSize?: number
search?: string
targetId?: number
}): WebSiteListResponse {
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const search = params?.search?.toLowerCase() || ''
const targetId = params?.targetId
let filtered = mockWebsites
if (targetId) {
filtered = filtered.filter(w => w.target === targetId)
}
if (search) {
filtered = filtered.filter(
w =>
w.url.toLowerCase().includes(search) ||
w.title.toLowerCase().includes(search) ||
w.host.toLowerCase().includes(search)
)
}
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 getMockWebsiteById(id: number): WebSite | undefined {
return mockWebsites.find(w => w.id === id)
}

71
frontend/mock/index.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Mock 数据统一导出
*
* 使用方式:
* import { USE_MOCK, mockData } from '@/mock'
*
* if (USE_MOCK) {
* return mockData.dashboard.assetStatistics
* }
*/
export { USE_MOCK, MOCK_DELAY, mockDelay } from './config'
// Dashboard
export {
mockDashboardStats,
mockAssetStatistics,
mockStatisticsHistory7Days,
mockStatisticsHistory30Days,
getMockStatisticsHistory,
} from './data/dashboard'
// Organizations
export {
mockOrganizations,
getMockOrganizations,
} from './data/organizations'
// Targets
export {
mockTargets,
mockTargetDetails,
getMockTargets,
getMockTargetById,
} from './data/targets'
// Scans
export {
mockScans,
mockScanStatistics,
getMockScans,
getMockScanById,
} from './data/scans'
// Vulnerabilities
export {
mockVulnerabilities,
getMockVulnerabilities,
getMockVulnerabilityById,
} from './data/vulnerabilities'
// Endpoints
export {
mockEndpoints,
getMockEndpoints,
getMockEndpointById,
} from './data/endpoints'
// Websites
export {
mockWebsites,
getMockWebsites,
getMockWebsiteById,
} from './data/websites'
// Subdomains
export {
mockSubdomains,
getMockSubdomains,
getMockSubdomainById,
} from './data/subdomains'

View File

@@ -3,9 +3,12 @@ import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
// Check if running on Vercel
const isVercel = process.env.VERCEL === '1';
const nextConfig: NextConfig = {
// Use standalone mode for Docker deployment
output: 'standalone',
// Use standalone mode for Docker deployment (not needed on Vercel)
...(isVercel ? {} : { output: 'standalone' }),
// Disable Next.js automatic add/remove trailing slash behavior
// Let us manually control URL format
skipTrailingSlashRedirect: true,
@@ -17,6 +20,10 @@ const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.*.*', '10.*.*.*', '172.16.*.*'],
async rewrites() {
// Skip rewrites on Vercel when using mock data
if (isVercel) {
return [];
}
// Use server service name in Docker environment, localhost for local development
const apiHost = process.env.API_HOST || 'localhost';
return [

View File

@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev:mock": "NEXT_PUBLIC_USE_MOCK=true next dev --turbopack",
"dev:noauth": "NEXT_PUBLIC_SKIP_AUTH=true next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
@@ -53,6 +54,7 @@
"cron-parser": "^5.4.0",
"cronstrue": "^3.9.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26",
"geist": "^1.5.1",
"is-ip": "^5.0.1",
"js-yaml": "^4.1.0",

View File

@@ -137,6 +137,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
framer-motion:
specifier: ^12.23.26
version: 12.23.26(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
geist:
specifier: ^1.5.1
version: 1.5.1(next@15.5.9(react-dom@19.1.2(react@19.1.2))(react@19.1.2))
@@ -2311,6 +2314,20 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
framer-motion@12.23.26:
resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -2767,6 +2784,12 @@ packages:
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -5577,6 +5600,15 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
framer-motion@12.23.26(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
dependencies:
motion-dom: 12.23.23
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 19.1.2
react-dom: 19.1.2(react@19.1.2)
function-bind@1.1.2: {}
function-timeout@0.1.1: {}
@@ -5996,6 +6028,12 @@ snapshots:
dompurify: 3.2.7
marked: 14.0.0
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
ms@2.1.3: {}
msw@2.11.6(@types/node@20.19.19)(typescript@5.9.3):

View File

@@ -1,7 +1,12 @@
import { api } from '@/lib/api-client'
import type { DashboardStats, AssetStatistics, StatisticsHistoryItem } from '@/types/dashboard.types'
import { USE_MOCK, mockDelay, mockDashboardStats, mockAssetStatistics, getMockStatisticsHistory } from '@/mock'
export async function getDashboardStats(): Promise<DashboardStats> {
if (USE_MOCK) {
await mockDelay()
return mockDashboardStats
}
const res = await api.get<DashboardStats>('/dashboard/stats/')
return res.data
}
@@ -10,6 +15,10 @@ export async function getDashboardStats(): Promise<DashboardStats> {
* Get asset statistics data (pre-aggregated)
*/
export async function getAssetStatistics(): Promise<AssetStatistics> {
if (USE_MOCK) {
await mockDelay()
return mockAssetStatistics
}
const res = await api.get<AssetStatistics>('/assets/statistics/')
return res.data
}
@@ -18,6 +27,10 @@ export async function getAssetStatistics(): Promise<AssetStatistics> {
* Get statistics history data (for line charts)
*/
export async function getStatisticsHistory(days: number = 7): Promise<StatisticsHistoryItem[]> {
if (USE_MOCK) {
await mockDelay()
return getMockStatisticsHistory(days)
}
const res = await api.get<StatisticsHistoryItem[]>('/assets/statistics/history/', {
params: { days }
})

View File

@@ -8,6 +8,7 @@ import type {
BatchDeleteEndpointsRequest,
BatchDeleteEndpointsResponse
} from "@/types/endpoint.types"
import { USE_MOCK, mockDelay, getMockEndpoints, getMockEndpointById } from '@/mock'
// Bulk create endpoints response type
export interface BulkCreateEndpointsResponse {
@@ -38,6 +39,12 @@ export class EndpointService {
* @returns Promise<Endpoint>
*/
static async getEndpointById(id: number): Promise<Endpoint> {
if (USE_MOCK) {
await mockDelay()
const endpoint = getMockEndpointById(id)
if (!endpoint) throw new Error('Endpoint not found')
return endpoint
}
const response = await api.get<Endpoint>(`/endpoints/${id}/`)
return response.data
}
@@ -48,6 +55,10 @@ export class EndpointService {
* @returns Promise<GetEndpointsResponse>
*/
static async getEndpoints(params: GetEndpointsRequest): Promise<GetEndpointsResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockEndpoints(params)
}
// api-client.ts automatically converts camelCase params to snake_case
const response = await api.get<GetEndpointsResponse>('/endpoints/', {
params

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client"
import type { Organization, OrganizationsResponse } from "@/types/organization.types"
import { USE_MOCK, mockDelay, getMockOrganizations, mockOrganizations } from '@/mock'
export class OrganizationService {
@@ -18,6 +19,10 @@ export class OrganizationService {
pageSize?: number
search?: string
}): Promise<OrganizationsResponse<Organization>> {
if (USE_MOCK) {
await mockDelay()
return getMockOrganizations(params)
}
const response = await api.get<OrganizationsResponse<Organization>>(
'/organizations/',
{ params }
@@ -31,6 +36,12 @@ export class OrganizationService {
* @returns Promise<Organization>
*/
static async getOrganizationById(id: string | number): Promise<Organization> {
if (USE_MOCK) {
await mockDelay()
const org = mockOrganizations.find(o => o.id === Number(id))
if (!org) throw new Error('Organization not found')
return org
}
const response = await api.get<Organization>(`/organizations/${id}/`)
return response.data
}

View File

@@ -8,11 +8,16 @@ import type {
QuickScanResponse,
ScanRecord
} from '@/types/scan.types'
import { USE_MOCK, mockDelay, getMockScans, getMockScanById, mockScanStatistics } from '@/mock'
/**
* Get scan list
*/
export async function getScans(params?: GetScansParams): Promise<GetScansResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockScans(params)
}
const res = await api.get<GetScansResponse>('/scans/', { params })
return res.data
}
@@ -23,6 +28,12 @@ export async function getScans(params?: GetScansParams): Promise<GetScansRespons
* @returns Scan details
*/
export async function getScan(id: number): Promise<ScanRecord> {
if (USE_MOCK) {
await mockDelay()
const scan = getMockScanById(id)
if (!scan) throw new Error('Scan not found')
return scan
}
const res = await api.get<ScanRecord>(`/scans/${id}/`)
return res.data
}
@@ -95,6 +106,10 @@ export interface ScanStatistics {
* @returns Statistics data
*/
export async function getScanStatistics(): Promise<ScanStatistics> {
if (USE_MOCK) {
await mockDelay()
return mockScanStatistics
}
const res = await api.get<ScanStatistics>('/scans/statistics/')
return res.data
}

View File

@@ -0,0 +1,36 @@
import { api } from "@/lib/api-client"
import type { SearchParams, SearchResponse } from "@/types/search.types"
/**
* 资产搜索 API 服务
*
* 搜索语法:
* - field="value" 模糊匹配ILIKE %value%
* - field=="value" 精确匹配
* - field!="value" 不等于
* - && AND 连接
* - || OR 连接
*
* 示例:
* - host="api" && tech="nginx"
* - tech="vue" || tech="react"
* - status=="200" && host!="test"
*/
export class SearchService {
/**
* 搜索资产
* GET /api/assets/search/
*/
static async search(params: SearchParams): Promise<SearchResponse> {
const queryParams = new URLSearchParams()
if (params.q) queryParams.append('q', params.q)
if (params.page) queryParams.append('page', params.page.toString())
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
const response = await api.get<SearchResponse>(
`/assets/search/?${queryParams.toString()}`
)
return response.data
}
}

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client"
import type { Subdomain, GetSubdomainsParams, GetSubdomainsResponse, GetAllSubdomainsParams, GetAllSubdomainsResponse, GetSubdomainByIDResponse, BatchCreateSubdomainsResponse } from "@/types/subdomain.types"
import { USE_MOCK, mockDelay, getMockSubdomains, getMockSubdomainById } from '@/mock'
// Bulk create subdomains response type
export interface BulkCreateSubdomainsResponse {
@@ -48,6 +49,12 @@ export class SubdomainService {
* Get single subdomain details
*/
static async getSubdomainById(id: string | number): Promise<GetSubdomainByIDResponse> {
if (USE_MOCK) {
await mockDelay()
const subdomain = getMockSubdomainById(Number(id))
if (!subdomain) throw new Error('Subdomain not found')
return subdomain
}
const response = await api.get<GetSubdomainByIDResponse>(`/domains/${id}/`)
return response.data
}
@@ -164,6 +171,10 @@ export class SubdomainService {
/** Get all subdomains list (server-side pagination) */
static async getAllSubdomains(params?: GetAllSubdomainsParams): Promise<GetAllSubdomainsResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockSubdomains(params)
}
const response = await api.get<GetAllSubdomainsResponse>('/domains/', {
params: {
page: params?.page || 1,

View File

@@ -12,11 +12,16 @@ import type {
BatchCreateTargetsRequest,
BatchCreateTargetsResponse,
} from '@/types/target.types'
import { USE_MOCK, mockDelay, getMockTargets, getMockTargetById } from '@/mock'
/**
* Get all targets list (paginated)
*/
export async function getTargets(page = 1, pageSize = 10, search?: string): Promise<TargetsResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockTargets({ page, pageSize, search })
}
const response = await api.get<TargetsResponse>('/targets/', {
params: {
page,
@@ -31,6 +36,12 @@ export async function getTargets(page = 1, pageSize = 10, search?: string): Prom
* Get single target details
*/
export async function getTargetById(id: number): Promise<Target> {
if (USE_MOCK) {
await mockDelay()
const target = getMockTargetById(id)
if (!target) throw new Error('Target not found')
return target
}
const response = await api.get<Target>(`/targets/${id}/`)
return response.data
}

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client"
import type { GetVulnerabilitiesParams } from "@/types/vulnerability.types"
import type { GetVulnerabilitiesParams, Vulnerability } from "@/types/vulnerability.types"
import { USE_MOCK, mockDelay, getMockVulnerabilities } from '@/mock'
export class VulnerabilityService {
/** Get all vulnerabilities list (used by global vulnerabilities page) */
@@ -7,12 +8,22 @@ export class VulnerabilityService {
params: GetVulnerabilitiesParams,
filter?: string,
): Promise<any> {
if (USE_MOCK) {
await mockDelay()
return getMockVulnerabilities(params)
}
const response = await api.get(`/assets/vulnerabilities/`, {
params: { ...params, filter },
})
return response.data
}
/** Get single vulnerability by ID */
static async getVulnerabilityById(id: number): Promise<Vulnerability> {
const response = await api.get<Vulnerability>(`/assets/vulnerabilities/${id}/`)
return response.data
}
/** Get vulnerability snapshot list by scan task (used by scan history page) */
static async getVulnerabilitiesByScanId(
scanId: number,

View File

@@ -0,0 +1,54 @@
// 搜索结果类型
export interface SearchResult {
url: string
host: string
title: string
technologies: string[]
statusCode: number | null
responseHeaders: Record<string, string>
responseBody: string
vulnerabilities: Vulnerability[]
}
export interface Vulnerability {
id?: number
name: string
severity: 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown'
vulnType: string
url?: string
}
// 搜索状态
export type SearchState = 'initial' | 'searching' | 'results'
// 搜索响应类型
export interface SearchResponse {
results: SearchResult[]
total: number
page: number
pageSize: number
totalPages: number
}
// 搜索操作符类型
export type SearchOperator = '=' | '==' | '!='
// 单个搜索条件
export interface SearchCondition {
field: string
operator: SearchOperator
value: string
}
// 搜索表达式(支持 AND/OR 组合)
export interface SearchExpression {
conditions: SearchCondition[] // 同一组内的条件用 AND 连接
orGroups?: SearchExpression[] // 多组之间用 OR 连接
}
// 发送给后端的搜索参数
export interface SearchParams {
q?: string // 完整的搜索表达式字符串
page?: number
pageSize?: number
}

9
frontend/vercel.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "nextjs",
"buildCommand": "pnpm build",
"installCommand": "pnpm install",
"env": {
"NEXT_PUBLIC_USE_MOCK": "true"
}
}

View File

@@ -272,6 +272,43 @@ get_accelerated_image() {
echo "$image"
}
# 检测远程 PostgreSQL 是否有 pg_ivm 扩展
check_pg_ivm() {
local db_host="$1"
local db_port="$2"
local db_user="$3"
local db_password="$4"
local db_name="$5"
info "检测 pg_ivm 扩展..."
# 尝试创建 pg_ivm 扩展
if docker run --rm \
-e PGPASSWORD="$db_password" \
postgres:15 \
psql "postgresql://$db_user@$db_host:$db_port/$db_name" \
-c "CREATE EXTENSION IF NOT EXISTS pg_ivm;" 2>/dev/null; then
success "pg_ivm 扩展已启用"
return 0
else
echo
error "pg_ivm 扩展未安装或无法启用"
echo
echo -e "${YELLOW}=========================================="
echo -e "pg_ivm 是必需的扩展,用于增量维护物化视图"
echo -e "要求: PostgreSQL 13+ 版本"
echo -e "==========================================${RESET}"
echo
echo -e "请在远程 PostgreSQL 服务器上执行以下命令一键安装:"
echo
echo -e " ${BOLD}curl -sSL https://raw.githubusercontent.com/yyhuni/xingrin/main/docker/scripts/install-pg-ivm.sh | sudo bash${RESET}"
echo
echo -e "安装完成后,请重新运行 install.sh"
echo -e "${YELLOW}==========================================${RESET}"
return 1
fi
}
# 显示安装总结信息
show_summary() {
echo
@@ -553,6 +590,11 @@ if [ -f "$DOCKER_DIR/.env.example" ]; then
-c "CREATE DATABASE $prefect_db;" 2>/dev/null || true
success "数据库准备完成"
# 检测 pg_ivm 扩展
if ! check_pg_ivm "$db_host" "$db_port" "$db_user" "$db_password" "$db_name"; then
exit 1
fi
sed_inplace "s/^DB_HOST=.*/DB_HOST=$db_host/" "$DOCKER_DIR/.env"
sed_inplace "s/^DB_PORT=.*/DB_PORT=$db_port/" "$DOCKER_DIR/.env"
sed_inplace "s/^DB_USER=.*/DB_USER=$db_user/" "$DOCKER_DIR/.env"