Compare commits

...

14 Commits

Author SHA1 Message Date
yyhuni
766f045904 fix:ffuf并发问题 2025-12-25 18:02:25 +08:00
yyhuni
8acfe1cc33 调整日志级别 2025-12-25 17:44:31 +08:00
github-actions[bot]
7aec3eabb2 chore: bump version to v1.1.13 2025-12-25 08:29:39 +00:00
yyhuni
b1f11c36a4 fix:字典下载端口 2025-12-25 16:21:32 +08:00
yyhuni
d97fb5245a 修复:提示 2025-12-25 16:18:46 +08:00
github-actions[bot]
ddf9a1f5a4 chore: bump version to v1.1.12 2025-12-25 08:10:57 +00:00
yyhuni
47f9f96a4b 更新文档 2025-12-25 16:07:30 +08:00
yyhuni
6f43e73162 readme up 2025-12-25 16:06:01 +08:00
yyhuni
9b7d496f3e 更新:端口号为8083 2025-12-25 16:02:55 +08:00
github-actions[bot]
6390849d52 chore: bump version to v1.1.11 2025-12-25 03:58:05 +00:00
yyhuni
7a6d2054f6 更新:ui 2025-12-25 11:50:21 +08:00
yyhuni
73ebaab232 更新:ui 2025-12-25 11:31:25 +08:00
github-actions[bot]
11899b29c2 chore: bump version to v1.1.10 2025-12-25 03:20:57 +00:00
github-actions[bot]
877d2a56d1 chore: bump version to v1.1.9 2025-12-25 03:13:58 +00:00
27 changed files with 443 additions and 354 deletions

View File

@@ -181,7 +181,7 @@ sudo ./install.sh
### 访问服务
- **Web 界面**: `https://localhost`
- **Web 界面**: `https://ip:8083`
### 常用命令

View File

@@ -1 +1 @@
v1.1.8
v1.1.13

View File

@@ -242,8 +242,9 @@ class WorkerDeployConsumer(AsyncWebsocketConsumer):
return
# 远程 Worker 通过 nginx HTTPS 访问nginx 反代到后端 8888
# 使用 https://{PUBLIC_HOST} 而不是直连 8888 端口
heartbeat_api_url = f"https://{public_host}" # 基础 URLagent 会加 /api/...
# 使用 https://{PUBLIC_HOST}:{PUBLIC_PORT} 而不是直连 8888 端口
public_port = getattr(settings, 'PUBLIC_PORT', '8083')
heartbeat_api_url = f"https://{public_host}:{public_port}"
session_name = f'xingrin_deploy_{self.worker_id}'
remote_script_path = '/tmp/xingrin_deploy.sh'

View File

@@ -234,7 +234,7 @@ class TaskDistributor:
else:
# 远程:通过 Nginx 反向代理访问HTTPS不直连 8888 端口)
network_arg = ""
server_url = f"https://{settings.PUBLIC_HOST}"
server_url = f"https://{settings.PUBLIC_HOST}:{settings.PUBLIC_PORT}"
# 挂载路径(所有节点统一使用固定路径)
host_results_dir = settings.HOST_RESULTS_DIR # /opt/xingrin/results
@@ -251,9 +251,7 @@ class TaskDistributor:
"-e PREFECT_SERVER_EPHEMERAL_ENABLED=true", # 启用 ephemeral server本地临时服务器
"-e PREFECT_SERVER_EPHEMERAL_STARTUP_TIMEOUT_SECONDS=120", # 增加启动超时时间
"-e PREFECT_SERVER_DATABASE_CONNECTION_URL=sqlite+aiosqlite:////tmp/.prefect/prefect.db", # 使用 /tmp 下的 SQLite
"-e PREFECT_LOGGING_LEVEL=DEBUG", # 启用 DEBUG 级别日志
"-e PREFECT_LOGGING_SERVER_LEVEL=DEBUG", # Server 日志级别
"-e PREFECT_DEBUG_MODE=true", # 启用调试模式
"-e PREFECT_LOGGING_LEVEL=WARNING", # 日志级别(减少 DEBUG 噪音)
]
# 挂载卷

View File

@@ -238,7 +238,7 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
docker run -d --pull=always \
--name xingrin-agent \
--restart always \
-e HEARTBEAT_API_URL="https://{django_settings.PUBLIC_HOST}" \
-e HEARTBEAT_API_URL="https://{django_settings.PUBLIC_HOST}:{getattr(django_settings, 'PUBLIC_PORT', '8083')}" \
-e WORKER_ID="{worker_id}" \
-e IMAGE_TAG="{target_version}" \
-v /proc:/host/proc:ro \

View File

@@ -483,13 +483,23 @@ def _run_scans_concurrently(
logger.warning("没有有效的扫描任务")
continue
# 使用 ThreadPoolTaskRunner 并发执行
logger.info("开始并发提交 %d 个扫描任务...", len(scan_params_list))
# ============================================================
# 分批执行策略:控制实际并发的 ffuf 进程数
# ============================================================
total_tasks = len(scan_params_list)
logger.info("开始分批执行 %d 个扫描任务(每批 %d 个)...", total_tasks, max_workers)
with ThreadPoolTaskRunner(max_workers=max_workers) as task_runner:
# 提交所有任务
batch_num = 0
for batch_start in range(0, total_tasks, max_workers):
batch_end = min(batch_start + max_workers, total_tasks)
batch_params = scan_params_list[batch_start:batch_end]
batch_num += 1
logger.info("执行第 %d 批任务(%d-%d/%d...", batch_num, batch_start + 1, batch_end, total_tasks)
# 提交当前批次的任务(非阻塞,立即返回 future
futures = []
for params in scan_params_list:
for params in batch_params:
future = run_and_stream_save_directories_task.submit(
cmd=params['command'],
tool_name=tool_name,
@@ -504,12 +514,10 @@ def _run_scans_concurrently(
)
futures.append((params['idx'], params['site_url'], future))
logger.info("✓ 已提交 %d 个扫描任务,等待完成...", len(futures))
# 等待所有任务完成并聚合结果
# 等待当前批次所有任务完成(阻塞,确保本批完成后再启动下一批)
for idx, site_url, future in futures:
try:
result = future.result()
result = future.result() # 阻塞等待单个任务完成
directories_found = result.get('created_directories', 0)
total_directories += directories_found
processed_sites_count += 1
@@ -521,7 +529,6 @@ def _run_scans_concurrently(
except Exception as exc:
failed_sites.append(site_url)
# 判断是否为超时异常
if 'timeout' in str(exc).lower() or isinstance(exc, subprocess.TimeoutExpired):
logger.warning(
"⚠️ [%d/%d] 站点扫描超时: %s - 错误: %s",

View File

@@ -83,7 +83,8 @@ def ensure_wordlist_local(wordlist_name: str) -> str:
"无法确定 Django API 地址:请配置 SERVER_URL 或 PUBLIC_HOST 环境变量"
)
# 远程 Worker 通过 nginx HTTPS 访问,不再直连 8888
api_base = f"https://{public_host}/api"
public_port = getattr(settings, 'PUBLIC_PORT', '8083')
api_base = f"https://{public_host}:{public_port}/api"
query = urllib_parse.urlencode({'wordlist': wordlist_name})
download_url = f"{api_base.rstrip('/')}/wordlists/download/?{query}"

View File

@@ -290,6 +290,7 @@ NUCLEI_TEMPLATES_REPO_URL = os.getenv('NUCLEI_TEMPLATES_REPO_URL', 'https://gith
# 对外访问主机与端口(供 Worker 访问 Django 使用)
PUBLIC_HOST = os.getenv('PUBLIC_HOST', 'localhost').strip()
PUBLIC_PORT = os.getenv('PUBLIC_PORT', '8083').strip() # 对外 HTTPS 端口
SERVER_PORT = os.getenv('SERVER_PORT', '8888')
# ============================================

View File

@@ -125,20 +125,31 @@ class TestDataGenerator:
print("👷 创建 Worker 节点...")
cur = self.conn.cursor()
# 生成随机后缀确保唯一性
suffix = random.randint(1000, 9999)
regions = ['asia-singapore', 'asia-tokyo', 'asia-hongkong', 'europe-frankfurt', 'europe-london',
'us-east-virginia', 'us-west-oregon', 'us-central-iowa', 'australia-sydney', 'brazil-saopaulo']
statuses = ['online', 'offline', 'pending', 'deploying', 'maintenance']
workers = [
('local-worker-primary-node-for-internal-scanning-tasks', '127.0.0.1', True, 'online'),
('remote-worker-asia-pacific-region-singapore-datacenter-01', '192.168.1.100', False, 'online'),
('remote-worker-europe-west-region-frankfurt-datacenter-02', '192.168.1.101', False, 'offline'),
('remote-worker-north-america-east-region-virginia-datacenter-03', '192.168.1.102', False, 'pending'),
('remote-worker-asia-pacific-region-tokyo-datacenter-04', '192.168.1.103', False, 'deploying'),
(f'local-worker-primary-{suffix}', '127.0.0.1', True, 'online'),
]
# 随机生成 4-8 个远程 worker
num_remote = random.randint(4, 8)
selected_regions = random.sample(regions, min(num_remote, len(regions)))
for i, region in enumerate(selected_regions):
ip = f'192.168.{random.randint(1, 254)}.{random.randint(1, 254)}'
status = random.choice(statuses)
workers.append((f'remote-worker-{region}-{suffix}-{i:02d}', ip, False, status))
ids = []
for name, ip, is_local, status in workers:
cur.execute("""
INSERT INTO worker_node (name, ip_address, ssh_port, username, password, is_local, status, created_at, updated_at)
VALUES (%s, %s, 22, 'root', '', %s, %s, NOW(), NOW())
ON CONFLICT DO NOTHING
ON CONFLICT (name) DO UPDATE SET updated_at = NOW()
RETURNING id
""", (name, ip, is_local, status))
row = cur.fetchone()
@@ -153,19 +164,37 @@ class TestDataGenerator:
print("⚙️ 创建扫描引擎...")
cur = self.conn.cursor()
engines = [
('Full-Comprehensive-Security-Assessment-Engine-With-All-Modules-Enabled', 'subdomain_discovery:\n enabled: true\n tools: [subfinder, amass]\nvulnerability_scanning:\n enabled: true'),
('Quick-Reconnaissance-Engine-For-Fast-Surface-Discovery-Only', 'subdomain_discovery:\n enabled: true\n tools: [subfinder]\n timeout: 600'),
('Deep-Vulnerability-Assessment-Engine-With-Extended-Nuclei-Templates', 'vulnerability_scanning:\n enabled: true\n nuclei:\n severity: critical,high,medium,low,info'),
('Passive-Information-Gathering-Engine-No-Active-Probing', 'subdomain_discovery:\n enabled: true\n passive_only: true'),
suffix = random.randint(1000, 9999)
engine_templates = [
('Full-Comprehensive-Security-Assessment', 'subdomain_discovery:\n enabled: true\n tools: [subfinder, amass]\nvulnerability_scanning:\n enabled: true\n nuclei:\n severity: critical,high,medium,low,info\n rate_limit: {rate}\n concurrency: {conc}'),
('Quick-Reconnaissance-Fast-Discovery', 'subdomain_discovery:\n enabled: true\n tools: [subfinder]\n timeout: {timeout}\nport_scanning:\n enabled: true\n top_ports: {ports}'),
('Deep-Vulnerability-Assessment-Extended', 'vulnerability_scanning:\n enabled: true\n nuclei:\n severity: critical,high,medium,low,info\n templates: [cves, vulnerabilities, exposures]\n rate_limit: {rate}'),
('Passive-Information-Gathering-OSINT', 'subdomain_discovery:\n enabled: true\n passive_only: true\n sources: [crtsh, hackertarget, threatcrowd]\n timeout: {timeout}'),
('Web-Application-Security-Scanner', 'web_discovery:\n enabled: true\n httpx:\n threads: {conc}\nvulnerability_scanning:\n enabled: true\n dalfox:\n enabled: true'),
('API-Endpoint-Security-Audit', 'endpoint_discovery:\n enabled: true\n katana:\n depth: {depth}\n concurrency: {conc}\nvulnerability_scanning:\n enabled: true'),
('Infrastructure-Port-Scanner', 'port_scanning:\n enabled: true\n naabu:\n top_ports: {ports}\n rate: {rate}\n service_detection: true'),
('Directory-Bruteforce-Engine', 'directory_bruteforce:\n enabled: true\n ffuf:\n threads: {conc}\n wordlist: common.txt\n recursion_depth: {depth}'),
]
# 随机选择 4-6 个引擎模板
num_engines = random.randint(4, 6)
selected = random.sample(engine_templates, min(num_engines, len(engine_templates)))
ids = []
for name, config in engines:
for name_base, config_template in selected:
name = f'{name_base}-{suffix}'
config = config_template.format(
rate=random.choice([100, 150, 200, 300]),
conc=random.choice([10, 20, 50, 100]),
timeout=random.choice([300, 600, 900, 1200]),
ports=random.choice([100, 1000, 'full']),
depth=random.choice([2, 3, 4, 5])
)
cur.execute("""
INSERT INTO scan_engine (name, configuration, created_at, updated_at)
VALUES (%s, %s, NOW(), NOW())
ON CONFLICT (name) DO NOTHING
ON CONFLICT (name) DO UPDATE SET configuration = EXCLUDED.configuration, updated_at = NOW()
RETURNING id
""", (name, config))
row = cur.fetchone()
@@ -180,25 +209,39 @@ class TestDataGenerator:
print("🏢 创建组织...")
cur = self.conn.cursor()
orgs = [
('Acme Corporation International Holdings Limited - Global Technology Division', '全球领先的技术解决方案提供商,专注于企业级软件开发、云计算服务和网络安全解决方案。'),
('TechStart Innovation Labs - Research and Development Center', '专注于人工智能、机器学习和区块链技术研发的创新实验室。'),
('Global Financial Services Group - Digital Banking Platform', '提供全方位数字银行服务的金融科技公司,包括移动支付、在线贷款、投资理财等服务'),
('HealthCare Plus Medical Systems - Electronic Health Records Division', '医疗信息化解决方案提供商,专注于电子病历系统、医院信息管理系统和远程医疗平台开发'),
('E-Commerce Mega Platform - Asia Pacific Regional Operations', '亚太地区最大的电子商务平台之一,提供 B2B、B2C 和 C2C 多种交易模式'),
('Smart City Infrastructure Solutions - IoT and Sensor Networks', '智慧城市基础设施解决方案提供商,专注于物联网传感器网络、智能交通系统'),
('Educational Technology Consortium - Online Learning Platform', '在线教育技术联盟,提供 K-12 和高等教育在线学习平台'),
('Green Energy Solutions - Renewable Power Management Systems', '可再生能源管理系统提供商,专注于太阳能、风能发电站的监控、调度和优化管理'),
suffix = random.randint(1000, 9999)
org_templates = [
('Acme Corporation', '全球领先的技术解决方案提供商,专注于企业级软件开发、云计算服务和网络安全解决方案'),
('TechStart Innovation Labs', '专注于人工智能、机器学习和区块链技术研发的创新实验室'),
('Global Financial Services', '提供全方位数字银行服务的金融科技公司,包括移动支付、在线贷款、投资理财等服务'),
('HealthCare Plus Medical', '医疗信息化解决方案提供商,专注于电子病历系统、医院信息管理系统和远程医疗平台开发'),
('E-Commerce Mega Platform', '亚太地区最大的电子商务平台之一,提供 B2B、B2C 和 C2C 多种交易模式'),
('Smart City Infrastructure', '智慧城市基础设施解决方案提供商,专注于物联网传感器网络、智能交通系统'),
('Educational Technology', '在线教育技术联盟,提供 K-12 和高等教育在线学习平台。'),
('Green Energy Solutions', '可再生能源管理系统提供商,专注于太阳能、风能发电站的监控、调度和优化管理。'),
('CyberSec Defense Corp', '网络安全防御公司,提供渗透测试、漏洞评估和安全咨询服务。'),
('CloudNative Systems', '云原生系统开发商,专注于 Kubernetes、微服务架构和 DevOps 工具链。'),
('DataFlow Analytics', '大数据分析平台,提供实时数据处理、商业智能和预测分析服务。'),
('MobileFirst Technologies', '移动优先技术公司,专注于 iOS/Android 应用开发和跨平台解决方案。'),
]
divisions = ['Global Division', 'Asia Pacific', 'EMEA Region', 'Americas', 'R&D Center', 'Digital Platform', 'Cloud Services', 'Security Team']
# 随机选择 5-10 个组织
num_orgs = random.randint(5, 10)
selected = random.sample(org_templates, min(num_orgs, len(org_templates)))
ids = []
for name, desc in orgs:
for name_base, desc in selected:
division = random.choice(divisions)
name = f'{name_base} - {division} ({suffix})'
cur.execute("""
INSERT INTO organization (name, description, created_at, deleted_at)
VALUES (%s, %s, NOW(), NULL)
VALUES (%s, %s, NOW() - INTERVAL '%s days', NULL)
ON CONFLICT DO NOTHING
RETURNING id
""", (name, desc))
""", (name, desc, random.randint(0, 365)))
row = cur.fetchone()
if row:
ids.append(row[0])
@@ -212,69 +255,84 @@ class TestDataGenerator:
print("🎯 创建扫描目标...")
cur = self.conn.cursor()
domains = [
'api.acme-corporation-international-holdings.com',
'portal.techstart-innovation-labs-research.io',
'secure.global-financial-services-digital-banking.com',
'ehr.healthcare-plus-medical-systems-platform.org',
'shop.ecommerce-mega-platform-asia-pacific.com',
'dashboard.smart-city-infrastructure-iot-sensors.net',
'learn.educational-technology-consortium-online.edu',
'monitor.green-energy-solutions-renewable-power.com',
'admin.enterprise-resource-planning-system-v2.internal.corp',
'staging.customer-relationship-management-platform.dev',
'beta.supply-chain-management-logistics-tracking.io',
'test.human-resources-information-system-portal.local',
'dev.content-management-system-headless-api.example.com',
'qa.business-intelligence-analytics-dashboard.staging',
'uat.project-management-collaboration-tools.preview',
]
suffix = random.randint(1000, 9999)
ips = ['203.0.113.50', '198.51.100.100', '192.0.2.200', '203.0.113.150', '198.51.100.250']
cidrs = ['10.0.0.0/24', '172.16.0.0/16', '192.168.100.0/24']
# 域名前缀和后缀组合,增加随机性
prefixes = ['api', 'portal', 'secure', 'admin', 'dashboard', 'app', 'mobile', 'staging', 'dev', 'test', 'qa', 'uat', 'beta', 'prod', 'internal', 'external', 'public', 'private']
companies = ['acme', 'techstart', 'globalfinance', 'healthcare', 'ecommerce', 'smartcity', 'edutech', 'greenenergy', 'cybersec', 'cloudnative', 'dataflow', 'mobilefirst', 'secureops', 'devplatform']
tlds = ['.com', '.io', '.net', '.org', '.dev', '.app', '.cloud', '.tech', '.systems']
ids = []
# 域名目标
for i, domain in enumerate(domains):
# 随机生成 10-20 个域名目标
num_domains = random.randint(10, 20)
used_domains = set()
for i in range(num_domains):
prefix = random.choice(prefixes)
company = random.choice(companies)
tld = random.choice(tlds)
domain = f'{prefix}.{company}-{suffix}{tld}'
if domain in used_domains:
continue
used_domains.add(domain)
cur.execute("""
INSERT INTO target (name, type, created_at, last_scanned_at, deleted_at)
VALUES (%s, 'domain', NOW(), NOW() - INTERVAL '%s days', NULL)
VALUES (%s, 'domain', NOW() - INTERVAL '%s days', NOW() - INTERVAL '%s days', NULL)
ON CONFLICT DO NOTHING
RETURNING id
""", (domain, random.randint(0, 30)))
""", (domain, random.randint(30, 365), random.randint(0, 30)))
row = cur.fetchone()
if row:
ids.append(row[0])
# 关联到组织
if org_ids:
org_id = org_ids[i % len(org_ids)]
# 随机关联到组织
if org_ids and random.random() > 0.3: # 70% 概率关联
org_id = random.choice(org_ids)
cur.execute("""
INSERT INTO organization_targets (organization_id, target_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""", (org_id, row[0]))
# IP 目标
for ip in ips:
# 随机生成 3-8 个 IP 目标
num_ips = random.randint(3, 8)
for _ in range(num_ips):
# 使用文档保留的 IP 范围
ip_ranges = [
(203, 0, 113), # TEST-NET-3
(198, 51, 100), # TEST-NET-2
(192, 0, 2), # TEST-NET-1
]
base = random.choice(ip_ranges)
ip = f'{base[0]}.{base[1]}.{base[2]}.{random.randint(1, 254)}'
cur.execute("""
INSERT INTO target (name, type, created_at, last_scanned_at, deleted_at)
VALUES (%s, 'ip', NOW(), NOW() - INTERVAL '%s days', NULL)
VALUES (%s, 'ip', NOW() - INTERVAL '%s days', NOW() - INTERVAL '%s days', NULL)
ON CONFLICT DO NOTHING
RETURNING id
""", (ip, random.randint(0, 30)))
""", (ip, random.randint(30, 365), random.randint(0, 30)))
row = cur.fetchone()
if row:
ids.append(row[0])
# CIDR 目标
for cidr in cidrs:
# 随机生成 2-5 个 CIDR 目标
num_cidrs = random.randint(2, 5)
cidr_bases = ['10.0', '172.16', '172.17', '172.18', '192.168']
for _ in range(num_cidrs):
base = random.choice(cidr_bases)
third_octet = random.randint(0, 255)
mask = random.choice([24, 25, 26, 27, 28])
cidr = f'{base}.{third_octet}.0/{mask}'
cur.execute("""
INSERT INTO target (name, type, created_at, last_scanned_at, deleted_at)
VALUES (%s, 'cidr', NOW(), NOW() - INTERVAL '%s days', NULL)
VALUES (%s, 'cidr', NOW() - INTERVAL '%s days', NOW() - INTERVAL '%s days', NULL)
ON CONFLICT DO NOTHING
RETURNING id
""", (cidr, random.randint(0, 30)))
""", (cidr, random.randint(30, 365), random.randint(0, 30)))
row = cur.fetchone()
if row:
ids.append(row[0])
@@ -292,18 +350,49 @@ class TestDataGenerator:
return []
statuses = ['cancelled', 'completed', 'failed', 'initiated', 'running']
stages = ['subdomain_discovery', 'port_scanning', 'web_discovery', 'vulnerability_scanning']
status_weights = [0.05, 0.6, 0.1, 0.1, 0.15] # completed 占比最高
stages = ['subdomain_discovery', 'port_scanning', 'web_discovery', 'vulnerability_scanning', 'directory_bruteforce', 'endpoint_discovery']
error_messages = [
'Connection timeout while scanning target. Please check network connectivity.',
'DNS resolution failed for target domain.',
'Rate limit exceeded. Scan paused and will resume automatically.',
'Worker node disconnected during scan execution.',
'Insufficient disk space on worker node.',
'Target returned too many errors, scan aborted.',
'Authentication failed for protected resources.',
]
ids = []
for target_id in target_ids[:10]:
for _ in range(random.randint(2, 5)):
status = random.choice(statuses)
# 随机选择目标数量
num_targets = min(random.randint(8, 15), len(target_ids))
selected_targets = random.sample(target_ids, num_targets)
for target_id in selected_targets:
# 每个目标随机 1-6 个扫描任务
num_scans = random.randint(1, 6)
for _ in range(num_scans):
status = random.choices(statuses, weights=status_weights)[0]
engine_id = random.choice(engine_ids)
worker_id = random.choice(worker_ids) if worker_ids else None
progress = random.randint(0, 100) if status == 'running' else (100 if status == 'completed' else 0)
progress = random.randint(10, 95) if status == 'running' else (100 if status == 'completed' else random.randint(0, 50))
stage = random.choice(stages) if status == 'running' else ''
error_msg = 'Connection timeout while scanning target. Please check network connectivity.' if status == 'failed' else ''
error_msg = random.choice(error_messages) if status == 'failed' else ''
# 随机生成更真实的统计数据
subdomains = random.randint(5, 800)
websites = random.randint(2, 150)
endpoints = random.randint(20, 2000)
ips = random.randint(3, 100)
directories = random.randint(50, 3000)
vulns_critical = random.randint(0, 8)
vulns_high = random.randint(0, 20)
vulns_medium = random.randint(0, 40)
vulns_low = random.randint(0, 60)
vulns_total = vulns_critical + vulns_high + vulns_medium + vulns_low + random.randint(0, 30) # info
days_ago = random.randint(0, 90)
cur.execute("""
INSERT INTO scan (
@@ -321,12 +410,11 @@ class TestDataGenerator:
RETURNING id
""", (
target_id, engine_id, status, worker_id, progress, stage,
f'/app/results/scan_{target_id}', error_msg, '{}', '{}',
random.randint(10, 500), random.randint(5, 100), random.randint(50, 1000),
random.randint(5, 50), random.randint(100, 2000), random.randint(0, 50),
random.randint(0, 5), random.randint(0, 10), random.randint(0, 15), random.randint(0, 20),
random.randint(0, 60),
datetime.now() if status == 'completed' else None
f'/app/results/scan_{target_id}_{random.randint(1000, 9999)}', error_msg, '{}', '{}',
subdomains, websites, endpoints, ips, directories, vulns_total,
vulns_critical, vulns_high, vulns_medium, vulns_low,
days_ago,
datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 23)) if status in ['completed', 'failed', 'cancelled'] else None
))
row = cur.fetchone()
if row:
@@ -344,35 +432,72 @@ class TestDataGenerator:
print(" ⚠ 缺少引擎,跳过\n")
return
schedules = [
('Daily-Full-Security-Assessment-Scan-For-Production-Environment-Critical-Assets', '0 2 * * *', True),
('Weekly-Comprehensive-Vulnerability-Scan-For-All-External-Facing-Services', '0 3 * * 0', True),
('Monthly-Deep-Penetration-Testing-Scan-For-Internal-Network-Infrastructure', '0 4 1 * *', True),
('Hourly-Quick-Reconnaissance-Scan-For-New-Asset-Discovery-And-Monitoring', '0 * * * *', False),
('Bi-Weekly-Compliance-Check-Scan-For-PCI-DSS-And-SOC2-Requirements', '0 5 1,15 * *', True),
('Quarterly-Full-Infrastructure-Security-Audit-Scan-With-Extended-Templates', '0 6 1 1,4,7,10 *', True),
('Daily-API-Endpoint-Security-Scan-For-REST-And-GraphQL-Services', '30 1 * * *', True),
('Weekly-Web-Application-Vulnerability-Scan-For-Customer-Facing-Portals', '0 4 * * 1', False),
suffix = random.randint(1000, 9999)
schedule_templates = [
('Daily-Full-Security-Assessment', '0 {hour} * * *'),
('Weekly-Vulnerability-Scan', '0 {hour} * * {dow}'),
('Monthly-Penetration-Testing', '0 {hour} {dom} * *'),
('Hourly-Quick-Reconnaissance', '{min} * * * *'),
('Bi-Weekly-Compliance-Check', '0 {hour} 1,15 * *'),
('Quarterly-Infrastructure-Audit', '0 {hour} 1 1,4,7,10 *'),
('Daily-API-Security-Scan', '{min} {hour} * * *'),
('Weekly-Web-Application-Scan', '0 {hour} * * {dow}'),
('Nightly-Asset-Discovery', '0 {hour} * * *'),
('Weekend-Deep-Scan', '0 {hour} * * 0,6'),
('Business-Hours-Monitor', '0 9-17 * * 1-5'),
('Off-Hours-Intensive-Scan', '0 {hour} * * *'),
]
# 随机选择 6-12 个定时任务
num_schedules = random.randint(6, 12)
selected = random.sample(schedule_templates, min(num_schedules, len(schedule_templates)))
count = 0
for name, cron, enabled in schedules:
for name_base, cron_template in selected:
name = f'{name_base}-{suffix}-{count:02d}'
cron = cron_template.format(
hour=random.randint(0, 23),
min=random.randint(0, 59),
dow=random.randint(0, 6),
dom=random.randint(1, 28)
)
enabled = random.random() > 0.3 # 70% 启用
engine_id = random.choice(engine_ids)
org_id = random.choice(org_ids) if org_ids and random.choice([True, False]) else None
target_id = random.choice(target_ids) if target_ids and not org_id else None
# 随机决定关联组织还是目标
if org_ids and target_ids:
if random.random() > 0.5:
org_id = random.choice(org_ids)
target_id = None
else:
org_id = None
target_id = random.choice(target_ids)
elif org_ids:
org_id = random.choice(org_ids)
target_id = None
elif target_ids:
org_id = None
target_id = random.choice(target_ids)
else:
org_id = None
target_id = None
run_count = random.randint(0, 200)
has_run = random.random() > 0.2 # 80% 已运行过
cur.execute("""
INSERT INTO scheduled_scan (
name, engine_id, organization_id, target_id, cron_expression, is_enabled,
run_count, last_run_time, next_run_time, created_at, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() - INTERVAL '%s days', NOW())
ON CONFLICT DO NOTHING
""", (
name, engine_id, org_id, target_id, cron, enabled,
random.randint(0, 100),
datetime.now() - timedelta(days=random.randint(0, 7)) if random.choice([True, False]) else None,
datetime.now() + timedelta(hours=random.randint(1, 168))
))
run_count if has_run else 0,
datetime.now() - timedelta(days=random.randint(0, 14), hours=random.randint(0, 23)) if has_run else None,
datetime.now() + timedelta(hours=random.randint(1, 336)) # 最多 2 周后
, random.randint(30, 180)))
count += 1
print(f" ✓ 创建了 {count} 个定时扫描任务\n")
@@ -392,24 +517,39 @@ class TestDataGenerator:
'redis', 'mongo', 'elastic', 'vpn', 'remote', 'gateway', 'proxy',
'monitoring', 'metrics', 'grafana', 'prometheus', 'kibana', 'logs',
'jenkins', 'ci', 'cd', 'gitlab', 'jira', 'confluence', 'kubernetes', 'k8s',
'www', 'www2', 'www3', 'ns1', 'ns2', 'mx', 'mx1', 'mx2', 'autodiscover',
'webdisk', 'cpanel', 'whm', 'webmail2', 'email', 'smtp2', 'pop', 'pop3',
'imap2', 'calendar', 'contacts', 'drive', 'docs', 'sheets', 'slides',
'meet', 'chat', 'teams', 'slack', 'discord', 'zoom', 'video', 'stream',
'blog', 'news', 'press', 'media2', 'images', 'img', 'photos', 'video2',
'shop', 'store', 'cart', 'checkout', 'pay', 'payment', 'billing', 'invoice',
'support', 'help', 'helpdesk', 'ticket', 'tickets', 'status', 'health',
'api-v1', 'api-v2', 'api-v3', 'graphql', 'rest', 'soap', 'rpc', 'grpc',
]
# 二级前缀,用于生成更复杂的子域名
secondary_prefixes = ['', 'prod-', 'dev-', 'staging-', 'test-', 'int-', 'ext-', 'us-', 'eu-', 'ap-']
# 获取域名目标
cur.execute("SELECT id, name FROM target WHERE type = 'domain' AND deleted_at IS NULL LIMIT 8")
cur.execute("SELECT id, name FROM target WHERE type = 'domain' AND deleted_at IS NULL")
domain_targets = cur.fetchall()
count = 0
for target_id, target_name in domain_targets:
num = random.randint(20, 40)
# 每个目标随机 15-60 个子域名
num = random.randint(15, 60)
selected = random.sample(prefixes, min(num, len(prefixes)))
for prefix in selected:
subdomain_name = f'{prefix}.{target_name}'
# 随机添加二级前缀
sec_prefix = random.choice(secondary_prefixes) if random.random() > 0.7 else ''
subdomain_name = f'{sec_prefix}{prefix}.{target_name}'
cur.execute("""
INSERT INTO subdomain (name, target_id, created_at)
VALUES (%s, %s, NOW())
VALUES (%s, %s, NOW() - INTERVAL '%s days')
ON CONFLICT DO NOTHING
""", (subdomain_name, target_id))
""", (subdomain_name, target_id, random.randint(0, 90)))
count += 1
print(f" ✓ 创建了 {count} 个子域名\n")

View File

@@ -22,8 +22,10 @@ SERVER_PORT=8888
# 供远程 Worker 访问主服务器的地址:
# - 仅本地部署serverDocker 内部服务名)
# - 有远程 Worker改为主服务器外网 IP 或域名(如 192.168.1.100 或 xingrin.example.com
# 注意:远程 Worker 会通过 https://{PUBLIC_HOST} 访问nginx 反代到后端 8888
# 注意:远程 Worker 会通过 https://{PUBLIC_HOST}:{PUBLIC_PORT} 访问nginx 反代到后端 8888
PUBLIC_HOST=server
# 对外 HTTPS 端口
PUBLIC_PORT=8083
# ==================== Django 核心配置 ====================
# 生产环境务必更换为随机强密钥

View File

@@ -97,8 +97,7 @@ services:
frontend:
condition: service_started
ports:
- "80:80"
- "443:443"
- "8083:8083"
volumes:
# SSL 证书挂载(方便更新)
- ./nginx/ssl:/etc/nginx/ssl:ro

View File

@@ -95,8 +95,7 @@ services:
frontend:
condition: service_started
ports:
- "80:80"
- "443:443"
- "8083:8083"
volumes:
- ./nginx/ssl:/etc/nginx/ssl:ro

View File

@@ -16,17 +16,9 @@ http {
server frontend:3000;
}
# HTTP 跳转到 HTTPS
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# HTTPS 反代(将证书放在 /docker/nginx/ssl 下映射到 /etc/nginx/ssl
server {
listen 443 ssl http2;
listen 8083 ssl http2;
server_name _;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
@@ -36,6 +28,9 @@ http {
client_max_body_size 50m;
# HTTP 请求到 HTTPS 端口时自动跳转
error_page 497 =301 https://$host:$server_port$request_uri;
# 指纹特征 - 用于 FOFA/Shodan 等搜索引擎识别
add_header X-Powered-By "Xingrin ASM" always;

View File

@@ -12,11 +12,10 @@
- **操作系统**: Ubuntu 18.04+ / Debian 10+
- **权限**: sudo 管理员权限
- **端口要求**: 需要开放以下端口
- `80` - HTTP 访问(自动跳转到 HTTPS
- `443` - HTTPS 访问(主要访问端口)
- `8083` - HTTPS 访问(主要访问端口
- `5432` - PostgreSQL 数据库(如使用本地数据库)
- `6379` - Redis 缓存服务
- 后端 API 仅容器内监听 8888由 nginx 反代到 80/443对公网无需放行 8888
- 后端 API 仅容器内监听 8888由 nginx 反代到 8083对公网无需放行 8888
## 一键安装
@@ -45,7 +44,7 @@ sudo ./install.sh --no-frontend
### 3. 访问系统
安装完成后,访问:
- **Web 界面**: https://你的服务器IP/
- **Web 界面**: https://你的服务器IP:8083/
**默认账号**
- 用户名: `admin`
@@ -60,13 +59,10 @@ sudo ./install.sh --no-frontend
#### 必须放行的端口
```
80 - HTTP 访问
443 - HTTPS 访问
3000 - 前端服务(开发模式)
8083 - HTTPS 访问(主要访问端口)
5432 - PostgreSQL如使用本地数据库
6379 - Redis 缓存
```
> 后端 API 默认仅在容器内 8888 监听,由 nginx 反代到 80/443对公网无需放行 8888。
#### 推荐方案
- **国外 VPS**:如 Vultr、DigitalOcean、Linode 等,默认开放所有端口,无需额外配置
@@ -195,8 +191,7 @@ IMAGE_TAG=v1.0.0 # 镜像版本(自动设置)
#### 1. 端口被占用
```bash
# 检查端口占用
sudo netstat -tlnp | grep :80
sudo netstat -tlnp | grep :443
sudo netstat -tlnp | grep :8083
# 停止占用端口的服务
sudo systemctl stop apache2 # 如果是 Apache

View File

@@ -245,7 +245,7 @@ A: 更新字典内容后会重新计算 hashWorker 下次使用时会检测
A: 检查:
1. `PUBLIC_HOST` 是否配置为 Server 的外网 IP 或域名
2. Nginx 443 (HTTPS) 是否可达(远程 Worker 通过 nginx 访问后端)
2. Nginx 8083 (HTTPS) 是否可达(远程 Worker 通过 nginx 访问后端)
3. Worker 到 Server 的网络是否通畅
### Q: 如何批量导入字典?

View File

@@ -248,6 +248,31 @@
}
/* 登录页背景 - 使用主题色适配亮暗模式 */
.login-bg {
position: relative;
background-color: var(--background);
}
.login-bg::before {
content: '';
position: absolute;
inset: 0;
background-color: var(--primary);
opacity: 0.04;
mask-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
-webkit-mask-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
mask-size: 60px 60px;
-webkit-mask-size: 60px 60px;
pointer-events: none;
z-index: 0;
}
.login-bg > * {
position: relative;
z-index: 1;
}
/* 通知铃铛摇晃动画 */
@keyframes wiggle {
0%, 100% {

View File

@@ -83,7 +83,7 @@ export default function RootLayout({
{/* ThemeProvider 提供主题切换功能,跟随系统自动切换亮暗色 */}
<ThemeProvider
attribute="class"
defaultTheme="system"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>

View File

@@ -41,13 +41,7 @@ export default function LoginPage() {
// 加载中显示 spinner
if (authLoading) {
return (
<div
className="flex min-h-svh w-full flex-col items-center justify-center gap-4"
style={{
backgroundColor: '#DFDBE5',
backgroundImage: `url("data:image/svg+xml,%3Csvg width='180' height='180' viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M82.42 180h-1.415L0 98.995v-2.827L6.167 90 0 83.833V81.004L81.005 0h2.827L90 6.167 96.167 0H98.996L180 81.005v2.827L173.833 90 180 96.167V98.996L98.995 180h-2.827L90 173.833 83.833 180H82.42zm0-1.414L1.413 97.58 8.994 90l-7.58-7.58L82.42 1.413 90 8.994l7.58-7.58 81.006 81.005-7.58 7.58 7.58 7.58-81.005 81.006-7.58-7.58-7.58 7.58zM175.196 0h-25.832c1.033 2.924 2.616 5.59 4.625 7.868C152.145 9.682 151 12.208 151 15c0 5.523 4.477 10 10 10 1.657 0 3 1.343 3 3v4h16V0h-4.803c.51.883.803 1.907.803 3 0 3.314-2.686 6-6 6s-6-2.686-6-6c0-1.093.292-2.117.803-3h10.394-13.685C161.18.938 161 1.948 161 3v4c-4.418 0-8 3.582-8 8s3.582 8 8 8c2.76 0 5 2.24 5 5v2h4v-4h2v4h4v-4h2v4h2V0h-4.803zm-15.783 0c-.27.954-.414 1.96-.414 3v2.2c-1.25.254-2.414.74-3.447 1.412-1.716-1.93-3.098-4.164-4.054-6.612h7.914zM180 17h-3l2.143-10H180v10zm-30.635 163c-.884-2.502-1.365-5.195-1.365-8 0-13.255 10.748-24 23.99-24H180v32h-30.635zm12.147 0c.5-1.416 1.345-2.67 2.434-3.66l-1.345-1.48c-1.498 1.364-2.62 3.136-3.186 5.14H151.5c-.97-2.48-1.5-5.177-1.5-8 0-12.15 9.84-22 22-22h8v30h-18.488zm13.685 0c-1.037-1.793-2.976-3-5.197-3-2.22 0-4.16 1.207-5.197 3h10.394zM0 148h8.01C21.26 148 32 158.742 32 172c0 2.805-.48 5.498-1.366 8H0v-32zm0 2h8c12.15 0 22 9.847 22 22 0 2.822-.53 5.52-1.5 8h-7.914c-.567-2.004-1.688-3.776-3.187-5.14l-1.346 1.48c1.09.99 1.933 2.244 2.434 3.66H0v-30zm15.197 30c-1.037-1.793-2.976-3-5.197-3-2.22 0-4.16 1.207-5.197 3h10.394zM0 32h16v-4c0-1.657 1.343-3 3-3 5.523 0 10-4.477 10-10 0-2.794-1.145-5.32-2.992-7.134C28.018 5.586 29.6 2.924 30.634 0H0v32zm0-2h2v-4h2v4h4v-4h2v4h4v-2c0-2.76 2.24-5 5-5 4.418 0 8-3.582 8-8s-3.582-8-8-8V3c0-1.052-.18-2.062-.512-3H0v30zM28.5 0c-.954 2.448-2.335 4.683-4.05 6.613-1.035-.672-2.2-1.16-3.45-1.413V3c0-1.04-.144-2.046-.414-3H28.5zM0 17h3L.857 7H0v10zM15.197 0c.51.883.803 1.907.803 3 0 3.314-2.686 6-6 6S4 6.314 4 3c0-1.093.292-2.117.803-3h10.394zM109 115c-1.657 0-3 1.343-3 3v4H74v-4c0-1.657-1.343-3-3-3-5.523 0-10-4.477-10-10 0-2.793 1.145-5.318 2.99-7.132C60.262 93.638 58 88.084 58 82c0-13.255 10.748-24 23.99-24h16.02C111.26 58 122 68.742 122 82c0 6.082-2.263 11.636-5.992 15.866C117.855 99.68 119 102.206 119 105c0 5.523-4.477 10-10 10zm0-2c-2.76 0-5 2.24-5 5v2h-4v-4h-2v4h-4v-4h-2v4h-4v-4h-2v4h-4v-4h-2v4h-4v-2c0-2.76-2.24-5-5-5-4.418 0-8-3.582-8-8s3.582-8 8-8v-4c0-2.64 1.136-5.013 2.946-6.66L72.6 84.86C70.39 86.874 69 89.775 69 93v2.2c-1.25.254-2.414.74-3.447 1.412C62.098 92.727 60 87.61 60 82c0-12.15 9.84-22 22-22h16c12.15 0 22 9.847 22 22 0 5.61-2.097 10.728-5.55 14.613-1.035-.672-2.2-1.16-3.45-1.413V93c0-3.226-1.39-6.127-3.6-8.14l-1.346 1.48C107.864 87.987 109 90.36 109 93v4c4.418 0 8 3.582 8 8s-3.582 8-8 8zM90.857 97L93 107h-6l2.143-10h1.714zM80 99c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6zm20 0c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6z' fill='%239C92AC' fill-opacity='0.28' fill-rule='evenodd'/%3E%3C/svg%3E")`
}}
>
<div className="flex min-h-svh w-full flex-col items-center justify-center gap-4 bg-background">
<Spinner className="size-8 text-primary" />
<p className="text-muted-foreground text-sm" suppressHydrationWarning>loading...</p>
</div>
@@ -60,13 +54,7 @@ export default function LoginPage() {
}
return (
<div
className="flex min-h-svh flex-col p-6 md:p-10"
style={{
backgroundColor: '#DFDBE5',
backgroundImage: `url("data:image/svg+xml,%3Csvg width='180' height='180' viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M82.42 180h-1.415L0 98.995v-2.827L6.167 90 0 83.833V81.004L81.005 0h2.827L90 6.167 96.167 0H98.996L180 81.005v2.827L173.833 90 180 96.167V98.996L98.995 180h-2.827L90 173.833 83.833 180H82.42zm0-1.414L1.413 97.58 8.994 90l-7.58-7.58L82.42 1.413 90 8.994l7.58-7.58 81.006 81.005-7.58 7.58 7.58 7.58-81.005 81.006-7.58-7.58-7.58 7.58zM175.196 0h-25.832c1.033 2.924 2.616 5.59 4.625 7.868C152.145 9.682 151 12.208 151 15c0 5.523 4.477 10 10 10 1.657 0 3 1.343 3 3v4h16V0h-4.803c.51.883.803 1.907.803 3 0 3.314-2.686 6-6 6s-6-2.686-6-6c0-1.093.292-2.117.803-3h10.394-13.685C161.18.938 161 1.948 161 3v4c-4.418 0-8 3.582-8 8s3.582 8 8 8c2.76 0 5 2.24 5 5v2h4v-4h2v4h4v-4h2v4h2V0h-4.803zm-15.783 0c-.27.954-.414 1.96-.414 3v2.2c-1.25.254-2.414.74-3.447 1.412-1.716-1.93-3.098-4.164-4.054-6.612h7.914zM180 17h-3l2.143-10H180v10zm-30.635 163c-.884-2.502-1.365-5.195-1.365-8 0-13.255 10.748-24 23.99-24H180v32h-30.635zm12.147 0c.5-1.416 1.345-2.67 2.434-3.66l-1.345-1.48c-1.498 1.364-2.62 3.136-3.186 5.14H151.5c-.97-2.48-1.5-5.177-1.5-8 0-12.15 9.84-22 22-22h8v30h-18.488zm13.685 0c-1.037-1.793-2.976-3-5.197-3-2.22 0-4.16 1.207-5.197 3h10.394zM0 148h8.01C21.26 148 32 158.742 32 172c0 2.805-.48 5.498-1.366 8H0v-32zm0 2h8c12.15 0 22 9.847 22 22 0 2.822-.53 5.52-1.5 8h-7.914c-.567-2.004-1.688-3.776-3.187-5.14l-1.346 1.48c1.09.99 1.933 2.244 2.434 3.66H0v-30zm15.197 30c-1.037-1.793-2.976-3-5.197-3-2.22 0-4.16 1.207-5.197 3h10.394zM0 32h16v-4c0-1.657 1.343-3 3-3 5.523 0 10-4.477 10-10 0-2.794-1.145-5.32-2.992-7.134C28.018 5.586 29.6 2.924 30.634 0H0v32zm0-2h2v-4h2v4h4v-4h2v4h4v-2c0-2.76 2.24-5 5-5 4.418 0 8-3.582 8-8s-3.582-8-8-8V3c0-1.052-.18-2.062-.512-3H0v30zM28.5 0c-.954 2.448-2.335 4.683-4.05 6.613-1.035-.672-2.2-1.16-3.45-1.413V3c0-1.04-.144-2.046-.414-3H28.5zM0 17h3L.857 7H0v10zM15.197 0c.51.883.803 1.907.803 3 0 3.314-2.686 6-6 6S4 6.314 4 3c0-1.093.292-2.117.803-3h10.394zM109 115c-1.657 0-3 1.343-3 3v4H74v-4c0-1.657-1.343-3-3-3-5.523 0-10-4.477-10-10 0-2.793 1.145-5.318 2.99-7.132C60.262 93.638 58 88.084 58 82c0-13.255 10.748-24 23.99-24h16.02C111.26 58 122 68.742 122 82c0 6.082-2.263 11.636-5.992 15.866C117.855 99.68 119 102.206 119 105c0 5.523-4.477 10-10 10zm0-2c-2.76 0-5 2.24-5 5v2h-4v-4h-2v4h-4v-4h-2v4h-4v-4h-2v4h-4v-4h-2v4h-4v-2c0-2.76-2.24-5-5-5-4.418 0-8-3.582-8-8s3.582-8 8-8v-4c0-2.64 1.136-5.013 2.946-6.66L72.6 84.86C70.39 86.874 69 89.775 69 93v2.2c-1.25.254-2.414.74-3.447 1.412C62.098 92.727 60 87.61 60 82c0-12.15 9.84-22 22-22h16c12.15 0 22 9.847 22 22 0 5.61-2.097 10.728-5.55 14.613-1.035-.672-2.2-1.16-3.45-1.413V93c0-3.226-1.39-6.127-3.6-8.14l-1.346 1.48C107.864 87.987 109 90.36 109 93v4c4.418 0 8 3.582 8 8s-3.582 8-8 8zM90.857 97L93 107h-6l2.143-10h1.714zM80 99c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6zm20 0c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6z' fill='%239C92AC' fill-opacity='0.28' fill-rule='evenodd'/%3E%3C/svg%3E")`
}}
>
<div className="login-bg flex min-h-svh flex-col p-6 md:p-10">
{/* 主要内容区域 */}
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-sm md:max-w-4xl">

View File

@@ -20,10 +20,10 @@ export function DashboardActivityTabs() {
</TabsList>
<TabsContent value="vulnerabilities" className="mt-0">
<VulnerabilitiesDetailView />
<VulnerabilitiesDetailView hideToolbar />
</TabsContent>
<TabsContent value="scans" className="mt-0">
<ScanHistoryList />
<ScanHistoryList hideToolbar />
</TabsContent>
</Tabs>
)

View File

@@ -14,7 +14,7 @@ import {
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { IconLayoutColumns, IconBug, IconRadar, IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight, IconSearch, IconLoader2, IconChevronDown } from "@tabler/icons-react"
import { IconLayoutColumns, IconBug, IconRadar, IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight, IconChevronDown } from "@tabler/icons-react"
import { Label } from "@/components/ui/label"
import {
Select,
@@ -31,7 +31,6 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
@@ -106,41 +105,18 @@ export function DashboardDataTable() {
const [vulnPagination, setVulnPagination] = React.useState({ pageIndex: 0, pageSize: 10 })
const [scanPagination, setScanPagination] = React.useState({ pageIndex: 0, pageSize: 10 })
// 服务端搜索状态
const [vulnSearchQuery, setVulnSearchQuery] = React.useState("")
const [scanSearchQuery, setScanSearchQuery] = React.useState("")
const [localVulnSearch, setLocalVulnSearch] = React.useState("")
const [localScanSearch, setLocalScanSearch] = React.useState("")
const [isVulnSearching, setIsVulnSearching] = React.useState(false)
const [isScanSearching, setIsScanSearching] = React.useState(false)
// 获取漏洞数据
const vulnQuery = useAllVulnerabilities({
page: vulnPagination.pageIndex + 1,
pageSize: vulnPagination.pageSize,
search: vulnSearchQuery || undefined,
})
// 获取扫描数据
const scanQuery = useScans({
page: scanPagination.pageIndex + 1,
pageSize: scanPagination.pageSize,
search: scanSearchQuery || undefined,
})
// 当请求完成时重置搜索状态
React.useEffect(() => {
if (!vulnQuery.isFetching && isVulnSearching) {
setIsVulnSearching(false)
}
}, [vulnQuery.isFetching, isVulnSearching])
React.useEffect(() => {
if (!scanQuery.isFetching && isScanSearching) {
setIsScanSearching(false)
}
}, [scanQuery.isFetching, isScanSearching])
// 删除扫描的 mutation
const deleteMutation = useMutation({
mutationFn: deleteScan,
@@ -157,19 +133,6 @@ export function DashboardDataTable() {
},
})
// 搜索处理
const handleVulnSearch = () => {
setIsVulnSearching(true)
setVulnSearchQuery(localVulnSearch)
setVulnPagination(prev => ({ ...prev, pageIndex: 0 }))
}
const handleScanSearch = () => {
setIsScanSearching(true)
setScanSearchQuery(localScanSearch)
setScanPagination(prev => ({ ...prev, pageIndex: 0 }))
}
const vulnerabilities = vulnQuery.data?.vulnerabilities ?? []
const scans = scanQuery.data?.results ?? []
@@ -302,10 +265,6 @@ export function DashboardDataTable() {
})
const currentTable = activeTab === "vulnerabilities" ? vulnTable : scanTable
const currentLocalSearch = activeTab === "vulnerabilities" ? localVulnSearch : localScanSearch
const setCurrentLocalSearch = activeTab === "vulnerabilities" ? setLocalVulnSearch : setLocalScanSearch
const handleCurrentSearch = activeTab === "vulnerabilities" ? handleVulnSearch : handleScanSearch
const isCurrentSearching = activeTab === "vulnerabilities" ? isVulnSearching : isScanSearching
const isLoading = activeTab === "vulnerabilities" ? vulnQuery.isLoading : scanQuery.isLoading
const pagination = activeTab === "vulnerabilities" ? vulnPagination : scanPagination
const setPagination = activeTab === "vulnerabilities" ? setVulnPagination : setScanPagination
@@ -371,7 +330,7 @@ export function DashboardDataTable() {
</AlertDialog>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
{/* Tab + 搜索框 + Columns 在同一行 */}
{/* Tab + Columns 在同一行 */}
<div className="flex items-center justify-between gap-4 mb-4">
<TabsList>
<TabsTrigger value="scans" className="gap-1.5">
@@ -384,46 +343,30 @@ export function DashboardDataTable() {
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<Input
placeholder={activeTab === "vulnerabilities" ? "搜索漏洞类型..." : "搜索目标名称..."}
value={currentLocalSearch}
onChange={(e) => setCurrentLocalSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCurrentSearch()}
className="h-8 w-[200px]"
/>
<Button variant="outline" size="sm" onClick={handleCurrentSearch} disabled={isCurrentSearching} className="h-8">
{isCurrentSearching ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconSearch className="h-4 w-4" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
Columns
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{currentTable
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
Columns
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{currentTable
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 表格内容 */}

View File

@@ -11,29 +11,14 @@ import type { ColumnDef } from "@tanstack/react-table"
export function DashboardScanHistory() {
const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 5 })
const [searchQuery, setSearchQuery] = React.useState("")
const [isSearching, setIsSearching] = React.useState(false)
const router = useRouter()
const handleSearchChange = (value: string) => {
setIsSearching(true)
setSearchQuery(value)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
const { data, isLoading, isFetching } = useScans({
const { data, isLoading } = useScans({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
status: 'running',
search: searchQuery || undefined,
})
React.useEffect(() => {
if (!isFetching && isSearching) {
setIsSearching(false)
}
}, [isFetching, isSearching])
const formatDate = React.useCallback((dateString: string) => new Date(dateString).toLocaleString("zh-CN", { hour12: false }), [])
const navigate = React.useCallback((path: string) => router.push(path), [router])
const handleDelete = React.useCallback(() => {}, [])
@@ -65,9 +50,7 @@ export function DashboardScanHistory() {
<ScanHistoryDataTable
data={data?.results ?? []}
columns={columns}
searchValue={searchQuery}
onSearch={handleSearchChange}
isSearching={isSearching}
hideToolbar
hidePagination
pagination={pagination}
setPagination={setPagination}

View File

@@ -245,7 +245,7 @@ export const createScanHistoryColumns = ({
{
accessorKey: "summary",
header: "Summary",
size: 250,
size: 420,
minSize: 150,
cell: ({ row }) => {
const summary = (row.getValue("summary") as {

View File

@@ -27,7 +27,11 @@ import { ScanProgressDialog, buildScanProgressData, type ScanProgressData } from
* 扫描历史列表组件
* 用于显示和管理扫描历史记录
*/
export function ScanHistoryList() {
interface ScanHistoryListProps {
hideToolbar?: boolean
}
export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
const queryClient = useQueryClient()
const [selectedScans, setSelectedScans] = useState<ScanRecord[]>([])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
@@ -259,7 +263,7 @@ export function ScanHistoryList() {
<ScanHistoryDataTable
data={scans}
columns={scanColumns as ColumnDef<ScanRecord>[]}
onBulkDelete={handleBulkDelete}
onBulkDelete={hideToolbar ? undefined : handleBulkDelete}
onSelectionChange={setSelectedScans}
searchPlaceholder="搜索目标名称..."
searchColumn="targetName"
@@ -275,6 +279,7 @@ export function ScanHistoryList() {
totalPages: data?.totalPages || 1,
}}
onPaginationChange={handlePaginationChange}
hideToolbar={hideToolbar}
/>
{/* 删除确认对话框 */}

View File

@@ -87,6 +87,7 @@ interface VulnerabilitiesDataTableProps {
onSelectionChange?: (selectedRows: Vulnerability[]) => void
onDownloadAll?: () => void
onDownloadSelected?: () => void
hideToolbar?: boolean
}
export function VulnerabilitiesDataTable({
@@ -102,6 +103,7 @@ export function VulnerabilitiesDataTable({
onSelectionChange,
onDownloadAll,
onDownloadSelected,
hideToolbar = false,
}: VulnerabilitiesDataTableProps) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
@@ -176,105 +178,107 @@ export function VulnerabilitiesDataTable({
return (
<div className="w-full space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
{/* 智能过滤输入框 */}
<SmartFilterInput
fields={VULNERABILITY_FILTER_FIELDS}
examples={VULNERABILITY_FILTER_EXAMPLES}
value={filterValue}
onSearch={handleFilterSearch}
className="flex-1 max-w-xl"
/>
{!hideToolbar && (
<div className="flex items-center justify-between">
{/* 智能过滤输入框 */}
<SmartFilterInput
fields={VULNERABILITY_FILTER_FIELDS}
examples={VULNERABILITY_FILTER_EXAMPLES}
value={filterValue}
onSearch={handleFilterSearch}
className="flex-1 max-w-xl"
/>
{/* 右侧操作按钮 */}
<div className="flex items-center space-x-2">
{/* 列显示控制 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
Columns
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id === "select" && "Select"}
{column.id === "title" && "Title"}
{column.id === "severity" && "Severity"}
{column.id === "status" && "Status"}
{column.id === "url" && "URL"}
{column.id === "createdAt" && "Created At"}
{column.id === "actions" && "Actions"}
{!["select", "title", "severity", "status", "url", "createdAt", "actions"].includes(column.id) && column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 下载按钮 */}
{(onDownloadAll || onDownloadSelected) && (
{/* 右侧操作按钮 */}
<div className="flex items-center space-x-2">
{/* 列显示控制 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconDownload />
Download
<IconLayoutColumns />
Columns
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Download Options</DropdownMenuLabel>
<DropdownMenuSeparator />
{onDownloadAll && (
<DropdownMenuItem onClick={onDownloadAll}>
<IconDownload className="h-4 w-4" />
Download All Vulnerabilities
</DropdownMenuItem>
)}
{onDownloadSelected && (
<DropdownMenuItem
onClick={onDownloadSelected}
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
>
<IconDownload className="h-4 w-4" />
Download Selected Vulnerabilities
</DropdownMenuItem>
)}
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id === "select" && "Select"}
{column.id === "title" && "Title"}
{column.id === "severity" && "Severity"}
{column.id === "status" && "Status"}
{column.id === "url" && "URL"}
{column.id === "createdAt" && "Created At"}
{column.id === "actions" && "Actions"}
{!["select", "title", "severity", "status", "url", "createdAt", "actions"].includes(column.id) && column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 批量删除按钮 */}
{onBulkDelete && (
<Button
onClick={onBulkDelete}
size="sm"
variant="outline"
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
className={
table.getFilteredSelectedRowModel().rows.length === 0
? "text-muted-foreground"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}
>
<IconTrash />
Delete
</Button>
)}
{/* 下载按钮 */}
{(onDownloadAll || onDownloadSelected) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconDownload />
Download
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Download Options</DropdownMenuLabel>
<DropdownMenuSeparator />
{onDownloadAll && (
<DropdownMenuItem onClick={onDownloadAll}>
<IconDownload className="h-4 w-4" />
Download All Vulnerabilities
</DropdownMenuItem>
)}
{onDownloadSelected && (
<DropdownMenuItem
onClick={onDownloadSelected}
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
>
<IconDownload className="h-4 w-4" />
Download Selected Vulnerabilities
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 批量删除按钮 */}
{onBulkDelete && (
<Button
onClick={onBulkDelete}
size="sm"
variant="outline"
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
className={
table.getFilteredSelectedRowModel().rows.length === 0
? "text-muted-foreground"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}
>
<IconTrash />
Delete
</Button>
)}
</div>
</div>
</div>
)}
<div className="rounded-md border overflow-x-auto">
<Table style={{ minWidth: table.getCenterTotalSize() }}>

View File

@@ -23,11 +23,14 @@ interface VulnerabilitiesDetailViewProps {
scanId?: number
/** 目标详情页面使用:按 target 维度查看漏洞 */
targetId?: number
/** 隐藏工具栏(搜索、列控制等) */
hideToolbar?: boolean
}
export function VulnerabilitiesDetailView({
scanId,
targetId,
hideToolbar = false,
}: VulnerabilitiesDetailViewProps) {
const [selectedVulnerabilities, setSelectedVulnerabilities] = useState<Vulnerability[]>([])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
@@ -212,6 +215,7 @@ export function VulnerabilitiesDetailView({
}}
onPaginationChange={handlePaginationChange}
onSelectionChange={setSelectedVulnerabilities}
hideToolbar={hideToolbar}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>

View File

@@ -26,8 +26,8 @@ const STORAGE_KEY = 'color-theme'
* 获取当前颜色主题
*/
function getStoredTheme(): ColorThemeId {
if (typeof window === 'undefined') return 'vercel'
return (localStorage.getItem(STORAGE_KEY) as ColorThemeId) || 'vercel'
if (typeof window === 'undefined') return 'vercel-dark'
return (localStorage.getItem(STORAGE_KEY) as ColorThemeId) || 'vercel-dark'
}
/**
@@ -43,7 +43,7 @@ function applyThemeAttribute(themeId: ColorThemeId) {
* 颜色主题 hook
*/
export function useColorTheme() {
const [theme, setThemeState] = useState<ColorThemeId>('vercel')
const [theme, setThemeState] = useState<ColorThemeId>('vercel-dark')
const [mounted, setMounted] = useState(false)
const { setTheme: setNextTheme } = useTheme()

View File

@@ -212,8 +212,7 @@ show_summary() {
fi
echo -e "${GREEN}访问地址:${RESET}"
printf " %-16s %s\n" "XingRin:" "https://${ACCESS_HOST}/"
echo -e " ${YELLOW}(HTTP 会自动跳转到 HTTPS)${RESET}"
printf " %-16s %s\n" "XingRin:" "https://${ACCESS_HOST}:8083/"
echo
echo -e "${YELLOW}默认登录账号:${RESET}"
@@ -233,7 +232,7 @@ show_summary() {
echo -e "${YELLOW}[!] 云服务器某些厂商默认开启了安全策略(阿里云/腾讯云/华为云等):${RESET}"
echo -e " 端口未放行可能导致无法访问或无法扫描强烈推荐用国外vps或者在云控制台放行"
echo -e " ${RESET}80, 443, 5432, 6379"
echo -e " ${RESET}8083, 5432, 6379"
echo
}