Compare commits

...

33 Commits

Author SHA1 Message Date
yyhuni
53ba03d1e5 支持kali 2026-01-08 10:14:12 +08:00
github-actions[bot]
89c44ebd05 chore: bump version to v1.5.2 2026-01-08 00:20:11 +00:00
yyhuni
e0e3419edb chore(docker): improve worker dockerfile reliability with retry mechanism
- Add retry mechanism for apt-get install to handle ARM64 mirror sync delays
- Use --no-install-recommends flag to reduce image size and installation time
- Split apt-get update and install commands for better layer caching
- Add fallback installation logic for packages in case of initial failure
- Include explanatory comment about ARM64 ports.ubuntu.com potential delays
- Maintain compatibility with both ARM64 and AMD64 architectures
2026-01-08 08:14:24 +08:00
yyhuni
52ee4684a7 chore(docker): add apt-get update before playwright dependencies
- Add apt-get update before installing playwright chromium dependencies
- Ensures package lists are refreshed before installing system dependencies
- Prevents potential package installation failures in Docker builds
2026-01-08 08:09:21 +08:00
yyhuni
ce8cebf11d chore(frontend): update pnpm-lock.yaml with @radix-ui/react-hover-card
- Add @radix-ui/react-hover-card@1.1.15 package resolution entry
- Add package snapshot with all required dependencies and peer dependencies
- Update lock file to reflect new hover card component dependency
- Ensures consistent dependency management across the frontend environment
2026-01-08 07:57:58 +08:00
yyhuni
ec006d8f54 chore(frontend): add @radix-ui/react-hover-card dependency
- Add @radix-ui/react-hover-card v1.1.6 to project dependencies
- Enables hover card UI component functionality for improved user interactions
- Maintains consistency with existing Radix UI component library usage
2026-01-08 07:56:07 +08:00
yyhuni
48976a570f docs: update README with screenshot feature and sponsorship info
- Add screenshot feature documentation to features section with Playwright details
- Include WebP format compression benefits and multi-source URL support
- Add screenshot stage to scan flow architecture diagram with styling
- Add fingerprint library table with counts for public distribution
- Add sponsorship section with WeChat Pay and Alipay QR codes
- Add sponsor appreciation table
- Update frontend dependencies with @radix-ui/react-visually-hidden package
- Remove redundant installation speed note from mirror parameter documentation
- Clean up demo link formatting in online demo section
2026-01-08 07:54:31 +08:00
yyhuni
5da7229873 feat(scan-overview): add yaml configuration tab and improve logs layout
- Add yaml_configuration field to ScanHistorySerializer for backend exposure
- Implement tabbed interface with Logs and Configuration tabs in scan overview
- Add YamlEditor component to display scan configuration in read-only mode
- Refactor logs section to show status bar only when logs tab is active
- Move auto-refresh toggle to logs tab header for better UX
- Add padding to stage progress items for improved visual alignment
- Add internationalization strings for new UI elements (en and zh)
- Update ScanHistory type to include yamlConfiguration field
- Improve tab switching state management with activeTab state
2026-01-08 07:31:54 +08:00
yyhuni
8bb737a9fa feat(scan-history): add auto-refresh toggle and improve layout
- Add auto-refresh toggle switch to scan logs section for manual control
- Implement flexible polling based on auto-refresh state and scan status
- Restructure scan overview layout to use left-right split (stages + logs)
- Move stage progress to left column with vulnerability statistics
- Implement scrollable logs panel on right side with proper height constraints
- Update component imports to use Switch and Label instead of Button
- Add full-height flex layout to parent containers for proper scrolling
- Refactor grid layout from 2-column to fixed-width left + flexible right
- Update translations for new UI elements and labels
- Improve responsive design with better flex constraints and min-height handling
2026-01-07 23:30:27 +08:00
yyhuni
2d018d33f3 优化扫描历史详细页面 2026-01-07 22:44:46 +08:00
yyhuni
0c07cc8497 refactor(scan-flows): simplify logger calls by splitting multiline strings
- Split multiline logger.info() calls into separate single-line calls in initiate_scan_flow.py
- Improved log readability by removing string concatenation with newlines and separators
- Refactored 6 logger.info() calls across sequential, parallel, and completion stages
- Updated subdomain_discovery_flow.py to use consistent single-line logger pattern
- Maintains same log output while improving code maintainability and consistency
2026-01-07 22:21:50 +08:00
yyhuni
225b039985 style(system-logs): adjust log level filter dropdown width
- Increase SelectTrigger width from 100px to 130px for better label visibility
- Improve UI consistency in log toolbar component
- Prevent text truncation in log level filter dropdown
2026-01-07 22:17:07 +08:00
yyhuni
d1624627bc 一级tab加图标 2026-01-07 22:14:42 +08:00
yyhuni
7bb15e4ae4 增加:截图功能 2026-01-07 22:10:51 +08:00
github-actions[bot]
8e8cc29669 chore: bump version to v1.4.1 2026-01-07 01:33:29 +00:00
yyhuni
d6d5338acb 增加资产删除功能 2026-01-07 09:29:31 +08:00
yyhuni
c521bdb511 重构:回退逻辑 2026-01-07 08:45:27 +08:00
yyhuni
abf2d95f6f feat(targets): increase max batch size for target creation from 1000 to 5000
- Update MAX_BATCH_SIZE constant in BatchCreateTargetSerializer from 1000 to 5000
- Increase batch creation limit to support larger bulk operations
- Update documentation comment to reflect new limit
- Allows users to create up to 5000 targets in a single batch operation
2026-01-06 20:39:31 +08:00
github-actions[bot]
ab58cf0d85 chore: bump version to v1.4.0 2026-01-06 09:31:29 +00:00
yyhuni
fb0111adf2 Merge branch 'dev' 2026-01-06 17:27:35 +08:00
yyhuni
161ee9a2b1 Merge branch 'dev' 2026-01-06 17:27:16 +08:00
yyhuni
0cf75585d5 docs: 添加黑名单过滤功能说明到 README 2026-01-06 17:25:31 +08:00
yyhuni
1d8d5f51d9 feat(blacklist): add mock data and service integration for blacklist management
- Create new blacklist mock data module with global and target-specific patterns
- Add mock functions for getting and updating global blacklist rules
- Add mock functions for getting and updating target-specific blacklist rules
- Integrate mock blacklist endpoints into global-blacklist.service.ts
- Integrate mock blacklist endpoints into target.service.ts
- Export blacklist mock functions from main mock index
- Enable testing of blacklist management UI without backend API
2026-01-06 17:08:51 +08:00
github-actions[bot]
3f8de07c8c chore: bump version to v1.4.0-dev 2026-01-06 09:02:31 +00:00
yyhuni
cd5c2b9f11 chore(notifications): remove test notification endpoint
- Remove test notification route from URL patterns
- Delete notifications_test view function and associated logic
- Clean up unused test endpoint that was used for development purposes
- Simplify notification API surface by removing non-production code
2026-01-06 16:57:29 +08:00
yyhuni
54786c22dd feat(scan): increase max batch size for quick scan operations
- Increase MAX_BATCH_SIZE from 1000 to 5000 in QuickScanSerializer
- Allows processing of larger batch scans in a single operation
- Improves throughput for bulk scanning workflows
2026-01-06 16:55:28 +08:00
yyhuni
d468f975ab feat(scan): implement fallback chain for endpoint URL export
- Add fallback chain for URL data sources: Endpoint → WebSite → default generation
- Import WebSite model and Path utility for enhanced file handling
- Create output directory automatically if it doesn't exist
- Add "source" field to return value indicating data origin (endpoint/website/default)
- Update docstring to document the three-tier fallback priority system
- Implement sequential export attempts with logging at each fallback stage
- Improve error handling and data source transparency for endpoint exports
2026-01-06 16:30:42 +08:00
yyhuni
a85a12b8ad feat(asset): create incremental materialized views for asset search
- Add pg_ivm extension for incremental materialized view maintenance
- Create asset_search_view for Website model with optimized columns for full-text search
- Create endpoint_search_view for Endpoint model with matching search schema
- Add database indexes on host, url, title, status_code, and created_at columns for both views
- Enable high-performance asset search queries with automatic view maintenance
2026-01-06 16:22:24 +08:00
yyhuni
a8b0d97b7b feat(targets): update navigation routes and enhance add button UI
- Change target detail navigation route from `/website/` to `/overview/`
- Update TargetNameCell click handler to use new overview route
- Update TargetRowActions onView handler to use new overview route
- Add IconPlus icon import from @tabler/icons-react
- Add icon to create target button for improved visual clarity
- Improves navigation consistency and button affordance in targets table
2026-01-06 16:14:54 +08:00
yyhuni
b8504921c2 feat(fingerprints): add JSONL format support for Goby fingerprint imports
- Add support for JSONL format parsing in addition to standard JSON for Goby fingerprints
- Update GobyFingerprintService to validate both standard format (name/logic/rule) and JSONL format (product/rule)
- Implement _parse_json_content() method to handle both JSON and JSONL file formats with proper error handling
- Add JSONL parsing logic in frontend import dialog with per-line validation and error reporting
- Update file import endpoint documentation to indicate JSONL format support
- Improve error messages for encoding and parsing failures to aid user debugging
- Enable seamless import of Goby fingerprint data from multiple source formats
2026-01-06 16:10:14 +08:00
yyhuni
ecfc1822fb style(target): update vulnerability icon color to muted foreground
- Change ShieldAlert icon color from red-500 to muted-foreground in target overview
- Improves visual consistency with design system color palette
- Reduces visual emphasis on vulnerability section for better UI balance
2026-01-06 12:01:59 +08:00
github-actions[bot]
81633642e6 chore: bump version to v1.3.16-dev 2026-01-06 03:55:16 +00:00
yyhuni
6ff86e14ec Update README.md 2026-01-06 09:59:55 +08:00
99 changed files with 4383 additions and 595 deletions

View File

@@ -27,7 +27,7 @@
## 🌐 在线 Demo
👉 **[https://xingrin.vercel.app/](https://xingrin.vercel.app/)**
**[https://xingrin.vercel.app/](https://xingrin.vercel.app/)**
> ⚠️ 仅用于 UI 展示,未接入后端数据库
@@ -69,11 +69,21 @@
- **自定义流程** - YAML 配置扫描流程,灵活编排
- **定时扫描** - Cron 表达式配置,自动化周期扫描
### 🚫 黑名单过滤
- **两层黑名单** - 全局黑名单 + Target 级黑名单,灵活控制扫描范围
- **智能规则识别** - 自动识别域名通配符(`*.gov`、IP、CIDR 网段
### 🔖 指纹识别
- **多源指纹库** - 内置 EHole、Goby、Wappalyzer、Fingers、FingerPrintHub、ARL 等 2.7W+ 指纹规则
- **自动识别** - 扫描流程自动执行,识别 Web 应用技术栈
- **指纹管理** - 支持查询、导入、导出指纹规则
### 📸 站点截图
- **自动截图** - 使用 Playwright 对发现的网站自动截图
- **WebP 格式** - 高压缩比存储500k图片压缩存储只占几十K
- **多来源支持** - 支持对 Websites、Endpoints 等不同来源的 URL 截图
- **资产关联** - 截图自动同步到资产表,方便查看
#### 扫描流程架构
完整的扫描流程包括子域名发现、端口扫描、站点发现、指纹识别、URL 收集、目录扫描、漏洞扫描等阶段
@@ -95,6 +105,7 @@ flowchart LR
direction TB
URL["URL 收集<br/>waymore, katana"]
DIR["目录扫描<br/>ffuf"]
SCREENSHOT["站点截图<br/>playwright"]
end
subgraph STAGE3["阶段 3: 漏洞检测"]
@@ -119,6 +130,7 @@ flowchart LR
style FINGER fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
style URL fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
style DIR fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
style SCREENSHOT fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
style VULN fill:#f0b27a,stroke:#e67e22,stroke-width:1px,color:#fff
```
@@ -225,7 +237,6 @@ sudo ./install.sh --mirror
> **💡 --mirror 参数说明**
> - 自动配置 Docker 镜像加速(国内镜像源)
> - 加速 Git 仓库克隆Nuclei 模板等)
> - 大幅提升安装速度,避免网络超时
### 访问服务
@@ -258,6 +269,32 @@ sudo ./uninstall.sh
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">
### 🎁 关注公众号免费领取指纹库
| 指纹库 | 数量 |
|--------|------|
| ehole.json | 21,977 |
| ARL.yaml | 9,264 |
| goby.json | 7,086 |
| FingerprintHub.json | 3,147 |
> 💡 关注公众号回复「指纹」即可获取
## ☕ 赞助支持
如果这个项目对你有帮助谢谢请我能喝杯蜜雪冰城你的star和赞助是我免费更新的动力
<p>
<img src="docs/wx_pay.jpg" alt="微信支付" width="200">
<img src="docs/zfb_pay.jpg" alt="支付宝" width="200">
</p>
### 🙏 感谢以下赞助
| 昵称 | 金额 |
|------|------|
| X闭关中 | ¥88 |
## ⚠️ 免责声明

View File

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

View File

@@ -0,0 +1,104 @@
"""
创建资产搜索物化视图(使用 pg_ivm 增量维护)
这些视图用于资产搜索功能,提供高性能的全文搜索能力。
"""
from django.db import migrations
class Migration(migrations.Migration):
"""创建资产搜索所需的增量物化视图"""
dependencies = [
('asset', '0001_initial'),
]
operations = [
# 1. 确保 pg_ivm 扩展已安装
migrations.RunSQL(
sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;",
reverse_sql="DROP EXTENSION IF EXISTS pg_ivm;",
),
# 2. 创建 Website 搜索视图
# 注意pg_ivm 不支持 ArrayField所以 tech 字段需要从原表 JOIN 获取
migrations.RunSQL(
sql="""
SELECT pgivm.create_immv('asset_search_view', $$
SELECT
w.id,
w.url,
w.host,
w.title,
w.status_code,
w.response_headers,
w.response_body,
w.content_type,
w.content_length,
w.webserver,
w.location,
w.vhost,
w.created_at,
w.target_id
FROM website w
$$);
""",
reverse_sql="DROP TABLE IF EXISTS asset_search_view CASCADE;",
),
# 3. 创建 Endpoint 搜索视图
migrations.RunSQL(
sql="""
SELECT pgivm.create_immv('endpoint_search_view', $$
SELECT
e.id,
e.url,
e.host,
e.title,
e.status_code,
e.response_headers,
e.response_body,
e.content_type,
e.content_length,
e.webserver,
e.location,
e.vhost,
e.created_at,
e.target_id
FROM endpoint e
$$);
""",
reverse_sql="DROP TABLE IF EXISTS endpoint_search_view CASCADE;",
),
# 4. 为搜索视图创建索引(加速查询)
migrations.RunSQL(
sql=[
# Website 搜索视图索引
"CREATE INDEX IF NOT EXISTS asset_search_view_host_idx ON asset_search_view (host);",
"CREATE INDEX IF NOT EXISTS asset_search_view_url_idx ON asset_search_view (url);",
"CREATE INDEX IF NOT EXISTS asset_search_view_title_idx ON asset_search_view (title);",
"CREATE INDEX IF NOT EXISTS asset_search_view_status_idx ON asset_search_view (status_code);",
"CREATE INDEX IF NOT EXISTS asset_search_view_created_idx ON asset_search_view (created_at DESC);",
# Endpoint 搜索视图索引
"CREATE INDEX IF NOT EXISTS endpoint_search_view_host_idx ON endpoint_search_view (host);",
"CREATE INDEX IF NOT EXISTS endpoint_search_view_url_idx ON endpoint_search_view (url);",
"CREATE INDEX IF NOT EXISTS endpoint_search_view_title_idx ON endpoint_search_view (title);",
"CREATE INDEX IF NOT EXISTS endpoint_search_view_status_idx ON endpoint_search_view (status_code);",
"CREATE INDEX IF NOT EXISTS endpoint_search_view_created_idx ON endpoint_search_view (created_at DESC);",
],
reverse_sql=[
"DROP INDEX IF EXISTS asset_search_view_host_idx;",
"DROP INDEX IF EXISTS asset_search_view_url_idx;",
"DROP INDEX IF EXISTS asset_search_view_title_idx;",
"DROP INDEX IF EXISTS asset_search_view_status_idx;",
"DROP INDEX IF EXISTS asset_search_view_created_idx;",
"DROP INDEX IF EXISTS endpoint_search_view_host_idx;",
"DROP INDEX IF EXISTS endpoint_search_view_url_idx;",
"DROP INDEX IF EXISTS endpoint_search_view_title_idx;",
"DROP INDEX IF EXISTS endpoint_search_view_status_idx;",
"DROP INDEX IF EXISTS endpoint_search_view_created_idx;",
],
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.7 on 2026-01-07 02:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('asset', '0002_create_search_views'),
('scan', '0001_initial'),
('targets', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Screenshot',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.TextField(help_text='截图对应的 URL')),
('image', models.BinaryField(help_text='截图 WebP 二进制数据(压缩后)')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
('target', models.ForeignKey(help_text='所属目标', on_delete=django.db.models.deletion.CASCADE, related_name='screenshots', to='targets.target')),
],
options={
'verbose_name': '截图',
'verbose_name_plural': '截图',
'db_table': 'screenshot',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['target'], name='screenshot_target__2f01f6_idx'), models.Index(fields=['-created_at'], name='screenshot_created_c0ad4b_idx')],
'constraints': [models.UniqueConstraint(fields=('target', 'url'), name='unique_screenshot_per_target')],
},
),
migrations.CreateModel(
name='ScreenshotSnapshot',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('url', models.TextField(help_text='截图对应的 URL')),
('image', models.BinaryField(help_text='截图 WebP 二进制数据(压缩后)')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
('scan', models.ForeignKey(help_text='所属的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='screenshot_snapshots', to='scan.scan')),
],
options={
'verbose_name': '截图快照',
'verbose_name_plural': '截图快照',
'db_table': 'screenshot_snapshot',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['scan'], name='screenshot__scan_id_fb8c4d_idx'), models.Index(fields=['-created_at'], name='screenshot__created_804117_idx')],
'constraints': [models.UniqueConstraint(fields=('scan', 'url'), name='unique_screenshot_per_scan_snapshot')],
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-01-07 13:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('asset', '0003_add_screenshot_models'),
]
operations = [
migrations.AddField(
model_name='screenshot',
name='status_code',
field=models.SmallIntegerField(blank=True, help_text='HTTP 响应状态码', null=True),
),
migrations.AddField(
model_name='screenshotsnapshot',
name='status_code',
field=models.SmallIntegerField(blank=True, help_text='HTTP 响应状态码', null=True),
),
]

View File

@@ -20,6 +20,12 @@ from .snapshot_models import (
VulnerabilitySnapshot,
)
# 截图模型
from .screenshot_models import (
Screenshot,
ScreenshotSnapshot,
)
# 统计模型
from .statistics_models import AssetStatistics, StatisticsHistory
@@ -39,6 +45,9 @@ __all__ = [
'HostPortMappingSnapshot',
'EndpointSnapshot',
'VulnerabilitySnapshot',
# 截图模型
'Screenshot',
'ScreenshotSnapshot',
# 统计模型
'AssetStatistics',
'StatisticsHistory',

View File

@@ -0,0 +1,80 @@
from django.db import models
class ScreenshotSnapshot(models.Model):
"""
截图快照
记录:某次扫描中捕获的网站截图
"""
id = models.AutoField(primary_key=True)
scan = models.ForeignKey(
'scan.Scan',
on_delete=models.CASCADE,
related_name='screenshot_snapshots',
help_text='所属的扫描任务'
)
url = models.TextField(help_text='截图对应的 URL')
status_code = models.SmallIntegerField(null=True, blank=True, help_text='HTTP 响应状态码')
image = models.BinaryField(help_text='截图 WebP 二进制数据(压缩后)')
created_at = models.DateTimeField(auto_now_add=True, help_text='创建时间')
class Meta:
db_table = 'screenshot_snapshot'
verbose_name = '截图快照'
verbose_name_plural = '截图快照'
ordering = ['-created_at']
indexes = [
models.Index(fields=['scan']),
models.Index(fields=['-created_at']),
]
constraints = [
models.UniqueConstraint(
fields=['scan', 'url'],
name='unique_screenshot_per_scan_snapshot'
),
]
def __str__(self):
return f'{self.url} (Scan #{self.scan_id})'
class Screenshot(models.Model):
"""
截图资产
存储:目标的最新截图(从快照同步)
"""
id = models.AutoField(primary_key=True)
target = models.ForeignKey(
'targets.Target',
on_delete=models.CASCADE,
related_name='screenshots',
help_text='所属目标'
)
url = models.TextField(help_text='截图对应的 URL')
status_code = models.SmallIntegerField(null=True, blank=True, help_text='HTTP 响应状态码')
image = models.BinaryField(help_text='截图 WebP 二进制数据(压缩后)')
created_at = models.DateTimeField(auto_now_add=True, help_text='创建时间')
updated_at = models.DateTimeField(auto_now=True, help_text='更新时间')
class Meta:
db_table = 'screenshot'
verbose_name = '截图'
verbose_name_plural = '截图'
ordering = ['-created_at']
indexes = [
models.Index(fields=['target']),
models.Index(fields=['-created_at']),
]
constraints = [
models.UniqueConstraint(
fields=['target', 'url'],
name='unique_screenshot_per_target'
),
]
def __str__(self):
return f'{self.url} (Target #{self.target_id})'

View File

@@ -7,6 +7,7 @@ from .models.snapshot_models import (
EndpointSnapshot,
VulnerabilitySnapshot,
)
from .models.screenshot_models import Screenshot, ScreenshotSnapshot
# 注意IPAddress 和 Port 模型已被重构为 HostPortMapping
@@ -290,3 +291,23 @@ class EndpointSnapshotSerializer(serializers.ModelSerializer):
'created_at',
]
read_only_fields = fields
# ==================== 截图序列化器 ====================
class ScreenshotListSerializer(serializers.ModelSerializer):
"""截图资产列表序列化器(不包含 image 字段)"""
class Meta:
model = Screenshot
fields = ['id', 'url', 'status_code', 'created_at', 'updated_at']
read_only_fields = fields
class ScreenshotSnapshotListSerializer(serializers.ModelSerializer):
"""截图快照列表序列化器(不包含 image 字段)"""
class Meta:
model = ScreenshotSnapshot
fields = ['id', 'url', 'status_code', 'created_at']
read_only_fields = fields

View File

@@ -0,0 +1,186 @@
"""
Playwright 截图服务
使用 Playwright 异步批量捕获网站截图
"""
import asyncio
import logging
from typing import Optional, AsyncGenerator
logger = logging.getLogger(__name__)
class PlaywrightScreenshotService:
"""Playwright 截图服务 - 异步多 Page 并发截图"""
# 内置默认值(不暴露给用户)
DEFAULT_VIEWPORT_WIDTH = 1920
DEFAULT_VIEWPORT_HEIGHT = 1080
DEFAULT_TIMEOUT = 30000 # 毫秒
DEFAULT_JPEG_QUALITY = 85
def __init__(
self,
viewport_width: int = DEFAULT_VIEWPORT_WIDTH,
viewport_height: int = DEFAULT_VIEWPORT_HEIGHT,
timeout: int = DEFAULT_TIMEOUT,
concurrency: int = 5
):
"""
初始化 Playwright 截图服务
Args:
viewport_width: 视口宽度(像素)
viewport_height: 视口高度(像素)
timeout: 页面加载超时时间(毫秒)
concurrency: 并发截图数
"""
self.viewport_width = viewport_width
self.viewport_height = viewport_height
self.timeout = timeout
self.concurrency = concurrency
async def capture_screenshot(self, url: str, page) -> tuple[Optional[bytes], Optional[int]]:
"""
捕获单个 URL 的截图
Args:
url: 目标 URL
page: Playwright Page 对象
Returns:
(screenshot_bytes, status_code) 元组
- screenshot_bytes: JPEG 格式的截图字节数据,失败返回 None
- status_code: HTTP 响应状态码,失败返回 None
"""
status_code = None
try:
# 尝试加载页面,即使返回错误状态码也继续截图
try:
response = await page.goto(url, timeout=self.timeout, wait_until='networkidle')
if response:
status_code = response.status
except Exception as goto_error:
# 页面加载失败4xx/5xx 或其他错误),但页面可能已部分渲染
# 仍然尝试截图以捕获错误页面
logger.debug("页面加载异常但尝试截图: %s, 错误: %s", url, str(goto_error)[:50])
# 尝试截图(即使 goto 失败)
screenshot_bytes = await page.screenshot(
type='jpeg',
quality=self.DEFAULT_JPEG_QUALITY,
full_page=False
)
return (screenshot_bytes, status_code)
except asyncio.TimeoutError:
logger.warning("截图超时: %s", url)
return (None, None)
except Exception as e:
logger.warning("截图失败: %s, 错误: %s", url, str(e)[:100])
return (None, None)
async def _capture_with_semaphore(
self,
url: str,
context,
semaphore: asyncio.Semaphore
) -> tuple[str, Optional[bytes], Optional[int]]:
"""
使用信号量控制并发的截图任务
Args:
url: 目标 URL
context: Playwright BrowserContext
semaphore: 并发控制信号量
Returns:
(url, screenshot_bytes, status_code) 元组
"""
async with semaphore:
page = await context.new_page()
try:
screenshot_bytes, status_code = await self.capture_screenshot(url, page)
return (url, screenshot_bytes, status_code)
finally:
await page.close()
async def capture_batch(
self,
urls: list[str]
) -> AsyncGenerator[tuple[str, Optional[bytes], Optional[int]], None]:
"""
批量捕获截图(异步生成器)
使用单个 BrowserContext + 多 Page 并发模式
通过 Semaphore 控制并发数
Args:
urls: URL 列表
Yields:
(url, screenshot_bytes, status_code) 元组
"""
if not urls:
return
from playwright.async_api import async_playwright
async with async_playwright() as p:
# 启动浏览器headless 模式)
browser = await p.chromium.launch(
headless=True,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'
]
)
try:
# 创建单个 context
context = await browser.new_context(
viewport={
'width': self.viewport_width,
'height': self.viewport_height
},
ignore_https_errors=True,
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
# 使用 Semaphore 控制并发
semaphore = asyncio.Semaphore(self.concurrency)
# 创建所有任务
tasks = [
self._capture_with_semaphore(url, context, semaphore)
for url in urls
]
# 使用 as_completed 实现流式返回
for coro in asyncio.as_completed(tasks):
result = await coro
yield result
await context.close()
finally:
await browser.close()
async def capture_batch_collect(
self,
urls: list[str]
) -> list[tuple[str, Optional[bytes], Optional[int]]]:
"""
批量捕获截图(收集所有结果)
Args:
urls: URL 列表
Returns:
[(url, screenshot_bytes, status_code), ...] 列表
"""
results = []
async for result in self.capture_batch(urls):
results.append(result)
return results

View File

@@ -0,0 +1,185 @@
"""
截图服务
负责截图的压缩、保存和同步
"""
import io
import logging
import os
from typing import Optional
from PIL import Image
logger = logging.getLogger(__name__)
class ScreenshotService:
"""截图服务 - 负责压缩、保存和同步"""
def __init__(self, max_width: int = 800, target_kb: int = 100):
"""
初始化截图服务
Args:
max_width: 最大宽度(像素)
target_kb: 目标文件大小KB
"""
self.max_width = max_width
self.target_kb = target_kb
def compress_screenshot(self, image_path: str) -> Optional[bytes]:
"""
压缩截图为 WebP 格式
Args:
image_path: PNG 截图文件路径
Returns:
压缩后的 WebP 二进制数据,失败返回 None
"""
if not os.path.exists(image_path):
logger.warning(f"截图文件不存在: {image_path}")
return None
try:
with Image.open(image_path) as img:
return self._compress_image(img)
except Exception as e:
logger.error(f"压缩截图失败: {image_path}, 错误: {e}")
return None
def compress_from_bytes(self, image_bytes: bytes) -> Optional[bytes]:
"""
从字节数据压缩截图为 WebP 格式
Args:
image_bytes: JPEG/PNG 图片字节数据
Returns:
压缩后的 WebP 二进制数据,失败返回 None
"""
if not image_bytes:
return None
try:
img = Image.open(io.BytesIO(image_bytes))
return self._compress_image(img)
except Exception as e:
logger.error(f"从字节压缩截图失败: {e}")
return None
def _compress_image(self, img: Image.Image) -> Optional[bytes]:
"""
压缩 PIL Image 对象为 WebP 格式
Args:
img: PIL Image 对象
Returns:
压缩后的 WebP 二进制数据
"""
try:
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
width, height = img.size
if width > self.max_width:
ratio = self.max_width / width
new_size = (self.max_width, int(height * ratio))
img = img.resize(new_size, Image.Resampling.LANCZOS)
quality = 80
while quality >= 10:
buffer = io.BytesIO()
img.save(buffer, format='WEBP', quality=quality, method=6)
if len(buffer.getvalue()) <= self.target_kb * 1024:
return buffer.getvalue()
quality -= 10
return buffer.getvalue()
except Exception as e:
logger.error(f"压缩图片失败: {e}")
return None
def save_screenshot_snapshot(
self,
scan_id: int,
url: str,
image_data: bytes,
status_code: int | None = None
) -> bool:
"""
保存截图快照到 ScreenshotSnapshot 表
Args:
scan_id: 扫描 ID
url: 截图对应的 URL
image_data: 压缩后的图片二进制数据
status_code: HTTP 响应状态码
Returns:
是否保存成功
"""
from apps.asset.models import ScreenshotSnapshot
try:
ScreenshotSnapshot.objects.update_or_create(
scan_id=scan_id,
url=url,
defaults={'image': image_data, 'status_code': status_code}
)
return True
except Exception as e:
logger.error(f"保存截图快照失败: scan_id={scan_id}, url={url}, 错误: {e}")
return False
def sync_screenshots_to_asset(self, scan_id: int, target_id: int) -> int:
"""
将扫描的截图快照同步到资产表
Args:
scan_id: 扫描 ID
target_id: 目标 ID
Returns:
同步的截图数量
"""
from apps.asset.models import Screenshot, ScreenshotSnapshot
snapshots = ScreenshotSnapshot.objects.filter(scan_id=scan_id)
count = 0
for snapshot in snapshots:
try:
Screenshot.objects.update_or_create(
target_id=target_id,
url=snapshot.url,
defaults={
'image': snapshot.image,
'status_code': snapshot.status_code
}
)
count += 1
except Exception as e:
logger.error(f"同步截图到资产表失败: url={snapshot.url}, 错误: {e}")
logger.info(f"同步截图完成: scan_id={scan_id}, target_id={target_id}, 数量={count}")
return count
def process_and_save_screenshot(self, scan_id: int, url: str, image_path: str) -> bool:
"""
处理并保存截图(压缩 + 保存快照)
Args:
scan_id: 扫描 ID
url: 截图对应的 URL
image_path: PNG 截图文件路径
Returns:
是否处理成功
"""
image_data = self.compress_screenshot(image_path)
if image_data is None:
return False
return self.save_screenshot_snapshot(scan_id, url, image_data)

View File

@@ -12,17 +12,22 @@ from .views import (
AssetStatisticsViewSet,
AssetSearchView,
AssetSearchExportView,
EndpointViewSet,
HostPortMappingViewSet,
ScreenshotViewSet,
)
# 创建 DRF 路由器
router = DefaultRouter()
# 注册 ViewSet
# 注意IPAddress 模型已被重构为 HostPortMapping相关路由已移除
router.register(r'subdomains', SubdomainViewSet, basename='subdomain')
router.register(r'websites', WebSiteViewSet, basename='website')
router.register(r'directories', DirectoryViewSet, basename='directory')
router.register(r'endpoints', EndpointViewSet, basename='endpoint')
router.register(r'ip-addresses', HostPortMappingViewSet, basename='ip-address')
router.register(r'vulnerabilities', VulnerabilityViewSet, basename='vulnerability')
router.register(r'screenshots', ScreenshotViewSet, basename='screenshot')
router.register(r'statistics', AssetStatisticsViewSet, basename='asset-statistics')
urlpatterns = [

View File

@@ -18,6 +18,8 @@ from .asset_views import (
EndpointSnapshotViewSet,
HostPortMappingSnapshotViewSet,
VulnerabilitySnapshotViewSet,
ScreenshotViewSet,
ScreenshotSnapshotViewSet,
)
from .search_views import AssetSearchView, AssetSearchExportView
@@ -35,6 +37,8 @@ __all__ = [
'EndpointSnapshotViewSet',
'HostPortMappingSnapshotViewSet',
'VulnerabilitySnapshotViewSet',
'ScreenshotViewSet',
'ScreenshotSnapshotViewSet',
'AssetSearchView',
'AssetSearchExportView',
]

View File

@@ -260,6 +260,35 @@ class SubdomainViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除子域名
POST /api/assets/subdomains/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import Subdomain
deleted_count, _ = Subdomain.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除子域名失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete subdomains',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class WebSiteViewSet(viewsets.ModelViewSet):
"""站点管理 ViewSet
@@ -393,6 +422,35 @@ class WebSiteViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除网站
POST /api/assets/websites/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import WebSite
deleted_count, _ = WebSite.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除网站失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete websites',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class DirectoryViewSet(viewsets.ModelViewSet):
"""目录管理 ViewSet
@@ -521,6 +579,35 @@ class DirectoryViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除目录
POST /api/assets/directories/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import Directory
deleted_count, _ = Directory.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除目录失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete directories',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class EndpointViewSet(viewsets.ModelViewSet):
"""端点管理 ViewSet
@@ -655,6 +742,35 @@ class EndpointViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除端点
POST /api/assets/endpoints/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import Endpoint
deleted_count, _ = Endpoint.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除端点失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete endpoints',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class HostPortMappingViewSet(viewsets.ModelViewSet):
"""主机端口映射管理 ViewSetIP 地址聚合视图)
@@ -728,6 +844,38 @@ class HostPortMappingViewSet(viewsets.ModelViewSet):
field_formatters=formatters
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除 IP 地址映射
POST /api/assets/ip-addresses/bulk-delete/
请求体: {"ips": ["192.168.1.1", "10.0.0.1"]}
响应: {"deletedCount": 3}
注意:由于 IP 地址是聚合显示的,删除时传入 IP 列表,
会删除该 IP 下的所有 host:port 映射记录
"""
ips = request.data.get('ips', [])
if not ips or not isinstance(ips, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ips is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import HostPortMapping
deleted_count, _ = HostPortMapping.objects.filter(ip__in=ips).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除 IP 地址映射失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete ip addresses',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class VulnerabilityViewSet(viewsets.ModelViewSet):
"""漏洞资产管理 ViewSet只读
@@ -1077,3 +1225,162 @@ class VulnerabilitySnapshotViewSet(viewsets.ModelViewSet):
if scan_pk:
return self.service.get_by_scan(scan_pk, filter_query=filter_query)
return self.service.get_all(filter_query=filter_query)
# ==================== 截图 ViewSet ====================
class ScreenshotViewSet(viewsets.ModelViewSet):
"""截图资产 ViewSet
支持两种访问方式:
1. 嵌套路由GET /api/targets/{target_pk}/screenshots/
2. 独立路由GET /api/screenshots/(全局查询)
支持智能过滤语法filter 参数):
- url="example" URL 模糊匹配
"""
from ..serializers import ScreenshotListSerializer
serializer_class = ScreenshotListSerializer
pagination_class = BasePagination
filter_backends = [filters.OrderingFilter]
ordering = ['-created_at']
def get_queryset(self):
"""根据是否有 target_pk 参数决定查询范围"""
from ..models import Screenshot
target_pk = self.kwargs.get('target_pk')
filter_query = self.request.query_params.get('filter', None)
queryset = Screenshot.objects.all()
if target_pk:
queryset = queryset.filter(target_id=target_pk)
if filter_query:
# 简单的 URL 模糊匹配
queryset = queryset.filter(url__icontains=filter_query)
return queryset.order_by('-created_at')
@action(detail=True, methods=['get'], url_path='image')
def image(self, request, pk=None, **kwargs):
"""获取截图图片
GET /api/assets/screenshots/{id}/image/
返回 WebP 格式的图片二进制数据
"""
from django.http import HttpResponse
from ..models import Screenshot
try:
screenshot = Screenshot.objects.get(pk=pk)
if not screenshot.image:
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Screenshot image not found',
status_code=status.HTTP_404_NOT_FOUND
)
response = HttpResponse(screenshot.image, content_type='image/webp')
response['Content-Disposition'] = f'inline; filename="screenshot_{pk}.webp"'
return response
except Screenshot.DoesNotExist:
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Screenshot not found',
status_code=status.HTTP_404_NOT_FOUND
)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request, **kwargs):
"""批量删除截图
POST /api/assets/screenshots/bulk-delete/
请求体: {"ids": [1, 2, 3]}
响应: {"deletedCount": 3}
"""
ids = request.data.get('ids', [])
if not ids or not isinstance(ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids is required and must be a list',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
from ..models import Screenshot
deleted_count, _ = Screenshot.objects.filter(id__in=ids).delete()
return success_response(data={'deletedCount': deleted_count})
except Exception as e:
logger.exception("批量删除截图失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete screenshots',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class ScreenshotSnapshotViewSet(viewsets.ModelViewSet):
"""截图快照 ViewSet - 嵌套路由GET /api/scans/{scan_pk}/screenshots/
支持智能过滤语法filter 参数):
- url="example" URL 模糊匹配
"""
from ..serializers import ScreenshotSnapshotListSerializer
serializer_class = ScreenshotSnapshotListSerializer
pagination_class = BasePagination
filter_backends = [filters.OrderingFilter]
ordering = ['-created_at']
def get_queryset(self):
"""根据 scan_pk 参数查询"""
from ..models import ScreenshotSnapshot
scan_pk = self.kwargs.get('scan_pk')
filter_query = self.request.query_params.get('filter', None)
queryset = ScreenshotSnapshot.objects.all()
if scan_pk:
queryset = queryset.filter(scan_id=scan_pk)
if filter_query:
# 简单的 URL 模糊匹配
queryset = queryset.filter(url__icontains=filter_query)
return queryset.order_by('-created_at')
@action(detail=True, methods=['get'], url_path='image')
def image(self, request, pk=None, **kwargs):
"""获取截图快照图片
GET /api/scans/{scan_pk}/screenshots/{id}/image/
返回 WebP 格式的图片二进制数据
"""
from django.http import HttpResponse
from ..models import ScreenshotSnapshot
try:
screenshot = ScreenshotSnapshot.objects.get(pk=pk)
if not screenshot.image:
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Screenshot image not found',
status_code=status.HTTP_404_NOT_FOUND
)
response = HttpResponse(screenshot.image, content_type='image/webp')
response['Content-Disposition'] = f'inline; filename="screenshot_snapshot_{pk}.webp"'
return response
except ScreenshotSnapshot.DoesNotExist:
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Screenshot snapshot not found',
status_code=status.HTTP_404_NOT_FOUND
)

View File

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

View File

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

View File

@@ -263,11 +263,16 @@ COMMAND_TEMPLATES = {
'directory_scan': DIRECTORY_SCAN_COMMANDS,
'url_fetch': URL_FETCH_COMMANDS,
'vuln_scan': VULN_SCAN_COMMANDS,
'screenshot': {}, # 使用 Python 原生库Playwright无命令模板
}
# ==================== 扫描类型配置 ====================
# 执行阶段定义(按顺序执行)
# Stage 1: 资产发现 - 子域名 → 端口 → 站点探测 → 指纹识别
# Stage 2: URL 收集 - URL 获取 + 目录扫描(并行)
# Stage 3: 截图 - 在 URL 收集完成后执行,捕获更多发现的页面
# Stage 4: 漏洞扫描 - 最后执行
EXECUTION_STAGES = [
{
'mode': 'sequential',
@@ -277,6 +282,10 @@ EXECUTION_STAGES = [
'mode': 'parallel',
'flows': ['url_fetch', 'directory_scan']
},
{
'mode': 'sequential',
'flows': ['screenshot']
},
{
'mode': 'sequential',
'flows': ['vuln_scan']

View File

@@ -101,6 +101,16 @@ directory_scan:
match-codes: 200,201,301,302,401,403 # 匹配的 HTTP 状态码
# rate: 0 # 每秒请求数(默认 0 不限制)
screenshot:
# ==================== 网站截图 ====================
# 使用 Playwright 对网站进行截图,保存为 WebP 格式
# 在 Stage 2 与 url_fetch、directory_scan 并行执行
tools:
playwright:
enabled: true
concurrency: 5 # 并发截图数(默认 5
url_sources: [websites] # URL 来源当前对website截图还可以用 [websites, endpoints]
url_fetch:
# ==================== URL 获取 ====================
tools:

View File

@@ -99,15 +99,13 @@ def initiate_scan_flow(
raise ValueError("engine_name is required")
logger.info(
"="*60 + "\n" +
"开始初始化扫描任务\n" +
f" Scan ID: {scan_id}\n" +
f" Target: {target_name}\n" +
f" Engine: {engine_name}\n" +
f" Workspace: {scan_workspace_dir}\n" +
"="*60
)
logger.info("="*60)
logger.info("开始初始化扫描任务")
logger.info(f"Scan ID: {scan_id}")
logger.info(f"Target: {target_name}")
logger.info(f"Engine: {engine_name}")
logger.info(f"Workspace: {scan_workspace_dir}")
logger.info("="*60)
# ==================== Task 1: 创建 Scan 工作空间 ====================
scan_workspace_path = setup_scan_workspace(scan_workspace_dir)
@@ -126,11 +124,9 @@ def initiate_scan_flow(
# FlowOrchestrator 已经解析了所有工具配置
enabled_tools_by_type = orchestrator.enabled_tools_by_type
logger.info(
f"执行计划生成成功:\n"
f" 扫描类型: {''.join(orchestrator.scan_types)}\n"
f" 总共 {len(orchestrator.scan_types)} 个 Flow"
)
logger.info("执行计划生成成功")
logger.info(f"扫描类型: {''.join(orchestrator.scan_types)}")
logger.info(f"总共 {len(orchestrator.scan_types)} 个 Flow")
# ==================== 初始化阶段进度 ====================
# 在解析完配置后立即初始化,此时已有完整的 scan_types 列表
@@ -209,9 +205,13 @@ def initiate_scan_flow(
for mode, enabled_flows in orchestrator.get_execution_stages():
if mode == 'sequential':
# 顺序执行
logger.info(f"\n{'='*60}\n顺序执行阶段: {', '.join(enabled_flows)}\n{'='*60}")
logger.info("="*60)
logger.info(f"顺序执行阶段: {', '.join(enabled_flows)}")
logger.info("="*60)
for scan_type, flow_func, flow_specific_kwargs in get_valid_flows(enabled_flows):
logger.info(f"\n{'='*60}\n执行 Flow: {scan_type}\n{'='*60}")
logger.info("="*60)
logger.info(f"执行 Flow: {scan_type}")
logger.info("="*60)
try:
result = flow_func(**flow_specific_kwargs)
record_flow_result(scan_type, result=result)
@@ -220,12 +220,16 @@ def initiate_scan_flow(
elif mode == 'parallel':
# 并行执行阶段:通过 Task 包装子 Flow并使用 Prefect TaskRunner 并发运行
logger.info(f"\n{'='*60}\n并行执行阶段: {', '.join(enabled_flows)}\n{'='*60}")
logger.info("="*60)
logger.info(f"并行执行阶段: {', '.join(enabled_flows)}")
logger.info("="*60)
futures = []
# 提交所有并行子 Flow 任务
for scan_type, flow_func, flow_specific_kwargs in get_valid_flows(enabled_flows):
logger.info(f"\n{'='*60}\n提交并行子 Flow 任务: {scan_type}\n{'='*60}")
logger.info("="*60)
logger.info(f"提交并行子 Flow 任务: {scan_type}")
logger.info("="*60)
future = _run_subflow_task.submit(
scan_type=scan_type,
flow_func=flow_func,
@@ -246,12 +250,10 @@ def initiate_scan_flow(
record_flow_result(scan_type, error=e)
# ==================== 完成 ====================
logger.info(
"="*60 + "\n" +
"✓ 扫描任务初始化完成\n" +
f" 执行的 Flow: {', '.join(executed_flows)}\n" +
"="*60
)
logger.info("="*60)
logger.info("✓ 扫描任务初始化完成")
logger.info(f"执行的 Flow: {', '.join(executed_flows)}")
logger.info("="*60)
# ==================== 返回结果 ====================
return {

View File

@@ -0,0 +1,202 @@
"""
截图 Flow
负责编排截图的完整流程:
1. 从数据库获取 URL 列表websites 和/或 endpoints
2. 批量截图并保存快照
3. 同步到资产表
"""
# Django 环境初始化
from apps.common.prefect_django_setup import setup_django_for_prefect
import logging
from pathlib import Path
from prefect import flow
from apps.scan.tasks.screenshot import capture_screenshots_task
from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_running,
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.utils import user_log
from apps.scan.services.target_export_service import (
get_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
def _parse_screenshot_config(enabled_tools: dict) -> dict:
"""
解析截图配置
Args:
enabled_tools: 启用的工具配置
Returns:
截图配置字典
"""
# 从 enabled_tools 中获取 playwright 配置
playwright_config = enabled_tools.get('playwright', {})
return {
'concurrency': playwright_config.get('concurrency', 5),
'url_sources': playwright_config.get('url_sources', ['websites'])
}
def _map_url_sources_to_data_sources(url_sources: list[str]) -> list[str]:
"""
将配置中的 url_sources 映射为 DataSource 常量
Args:
url_sources: 配置中的来源列表,如 ['websites', 'endpoints']
Returns:
DataSource 常量列表
"""
source_mapping = {
'websites': DataSource.WEBSITE,
'endpoints': DataSource.ENDPOINT,
}
sources = []
for source in url_sources:
if source in source_mapping:
sources.append(source_mapping[source])
else:
logger.warning("未知的 URL 来源: %s,跳过", source)
# 添加默认回退(从 subdomain 构造)
sources.append(DataSource.DEFAULT)
return sources
@flow(
name="screenshot",
log_prints=True,
on_running=[on_scan_flow_running],
on_completion=[on_scan_flow_completed],
on_failure=[on_scan_flow_failed],
)
def screenshot_flow(
scan_id: int,
target_name: str,
target_id: int,
scan_workspace_dir: str,
enabled_tools: dict
) -> dict:
"""
截图 Flow
工作流程:
Step 1: 解析配置
Step 2: 收集 URL 列表
Step 3: 批量截图并保存快照
Step 4: 同步到资产表
Args:
scan_id: 扫描任务 ID
target_name: 目标名称
target_id: 目标 ID
scan_workspace_dir: 扫描工作空间目录
enabled_tools: 启用的工具配置
Returns:
dict: {
'success': bool,
'scan_id': int,
'target': str,
'total_urls': int,
'successful': int,
'failed': int,
'synced': int
}
"""
try:
logger.info(
"="*60 + "\n" +
"开始截图扫描\n" +
f" Scan ID: {scan_id}\n" +
f" Target: {target_name}\n" +
f" Workspace: {scan_workspace_dir}\n" +
"="*60
)
user_log(scan_id, "screenshot", "Starting screenshot capture")
# Step 1: 解析配置
config = _parse_screenshot_config(enabled_tools)
concurrency = config['concurrency']
url_sources = config['url_sources']
logger.info("截图配置 - 并发: %d, URL来源: %s", concurrency, url_sources)
# Step 2: 使用统一服务收集 URL带黑名单过滤和回退
data_sources = _map_url_sources_to_data_sources(url_sources)
result = get_urls_with_fallback(target_id, sources=data_sources)
urls = result['urls']
logger.info(
"URL 收集完成 - 来源: %s, 数量: %d, 尝试过: %s",
result['source'], result['total_count'], result['tried_sources']
)
if not urls:
logger.warning("没有可截图的 URL跳过截图任务")
user_log(scan_id, "screenshot", "Skipped: no URLs to capture", "warning")
return {
'success': True,
'scan_id': scan_id,
'target': target_name,
'total_urls': 0,
'successful': 0,
'failed': 0,
'synced': 0
}
user_log(scan_id, "screenshot", f"Found {len(urls)} URLs to capture (source: {result['source']})")
# Step 3: 批量截图
logger.info("Step 3: 批量截图 - %d 个 URL", len(urls))
capture_result = capture_screenshots_task(
urls=urls,
scan_id=scan_id,
target_id=target_id,
config={'concurrency': concurrency}
)
# Step 4: 同步到资产表
logger.info("Step 4: 同步截图到资产表")
from apps.asset.services.screenshot_service import ScreenshotService
screenshot_service = ScreenshotService()
synced = screenshot_service.sync_screenshots_to_asset(scan_id, target_id)
logger.info(
"✓ 截图完成 - 总数: %d, 成功: %d, 失败: %d, 同步: %d",
capture_result['total'], capture_result['successful'], capture_result['failed'], synced
)
user_log(
scan_id, "screenshot",
f"Screenshot completed: {capture_result['successful']}/{capture_result['total']} captured, {synced} synced"
)
return {
'success': True,
'scan_id': scan_id,
'target': target_name,
'total_urls': capture_result['total'],
'successful': capture_result['successful'],
'failed': capture_result['failed'],
'synced': synced
}
except Exception as e:
logger.exception("截图 Flow 失败: %s", e)
user_log(scan_id, "screenshot", f"Screenshot failed: {e}", "error")
raise

View File

@@ -165,12 +165,12 @@ def _run_scans_sequentially(
for tool_name, tool_config in enabled_tools.items():
# 1. 构建完整命令(变量替换)
try:
command_params = {'url_file': urls_file}
command = build_scan_command(
tool_name=tool_name,
scan_type='site_scan',
command_params={
'url_file': urls_file
},
command_params=command_params,
tool_config=tool_config
)
except Exception as e:

View File

@@ -732,7 +732,9 @@ def subdomain_discovery_flow(
executed_tasks.append('save_domains')
# 记录 Flow 完成
logger.info("="*60 + "\n✓ 子域名发现扫描完成\n" + "="*60)
logger.info("="*60)
logger.info("✓ 子域名发现扫描完成")
logger.info("="*60)
user_log(scan_id, "subdomain_discovery", f"subdomain_discovery completed: found {processed_domains} subdomains")
return {

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-07 14:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scan', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='scan',
name='cached_screenshots_count',
field=models.IntegerField(default=0, help_text='缓存的截图数量'),
),
]

View File

@@ -84,6 +84,7 @@ class Scan(models.Model):
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_screenshots_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='缓存的高危漏洞数量')

View File

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

View File

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

View File

@@ -147,10 +147,10 @@ class FlowOrchestrator:
return True
return False
# 其他扫描类型:检查 tools
# 其他扫描类型(包括 screenshot:检查 tools
tools = scan_config.get('tools', {})
for tool_config in tools.values():
if tool_config.get('enabled', False):
if isinstance(tool_config, dict) and tool_config.get('enabled', False):
return True
return False
@@ -222,6 +222,10 @@ class FlowOrchestrator:
from apps.scan.flows.vuln_scan import vuln_scan_flow
return vuln_scan_flow
elif scan_type == 'screenshot':
from apps.scan.flows.screenshot_flow import screenshot_flow
return screenshot_flow
else:
logger.warning(f"未实现的扫描类型: {scan_type}")
return None

View File

@@ -464,6 +464,7 @@ class DjangoScanRepository:
'endpoints': scan.endpoint_snapshots.count(),
'ips': ips_count,
'directories': scan.directory_snapshots.count(),
'screenshots': scan.screenshot_snapshots.count(),
'vulns_total': total_vulns,
'vulns_critical': severity_stats['critical'],
'vulns_high': severity_stats['high'],
@@ -478,6 +479,7 @@ class DjangoScanRepository:
'cached_endpoints_count': stats['endpoints'],
'cached_ips_count': stats['ips'],
'cached_directories_count': stats['directories'],
'cached_screenshots_count': stats['screenshots'],
'cached_vulns_total': stats['vulns_total'],
'cached_vulns_critical': stats['vulns_critical'],
'cached_vulns_high': stats['vulns_high'],

View File

@@ -41,7 +41,7 @@ class ScanHistorySerializer(serializers.ModelSerializer):
fields = [
'id', 'target', 'target_name', 'engine_ids', 'engine_names',
'worker_name', 'created_at', 'status', 'error_message', 'summary',
'progress', 'current_stage', 'stage_progress'
'progress', 'current_stage', 'stage_progress', 'yaml_configuration'
]
def get_summary(self, obj):
@@ -51,6 +51,7 @@ class ScanHistorySerializer(serializers.ModelSerializer):
'endpoints': obj.cached_endpoints_count or 0,
'ips': obj.cached_ips_count or 0,
'directories': obj.cached_directories_count or 0,
'screenshots': obj.cached_screenshots_count or 0,
}
summary['vulnerabilities'] = {
'total': obj.cached_vulns_total or 0,
@@ -65,7 +66,7 @@ class ScanHistorySerializer(serializers.ModelSerializer):
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""快速扫描序列化器"""
MAX_BATCH_SIZE = 1000
MAX_BATCH_SIZE = 5000
targets = serializers.ListField(
child=serializers.DictField(),

View File

@@ -17,7 +17,12 @@ from .scan_state_service import ScanStateService
from .scan_control_service import ScanControlService
from .scan_stats_service import ScanStatsService
from .scheduled_scan_service import ScheduledScanService
from .target_export_service import TargetExportService
from .target_export_service import (
TargetExportService,
create_export_service,
export_urls_with_fallback,
DataSource,
)
__all__ = [
'ScanService', # 主入口(向后兼容)
@@ -27,5 +32,8 @@ __all__ = [
'ScanStatsService',
'ScheduledScanService',
'TargetExportService', # 目标导出服务
'create_export_service',
'export_urls_with_fallback',
'DataSource',
]

View File

@@ -2,7 +2,9 @@
目标导出服务
提供统一的目标提取和文件导出功能,支持:
- URL 导出(流式写入 + 默认值回退)
- URL 导出(纯导出,不做隐式回退)
- 默认 URL 生成(独立方法)
- 带回退链的 URL 导出(用例层编排)
- 域名/IP 导出(用于端口扫描)
- 黑名单过滤集成
"""
@@ -10,7 +12,7 @@
import ipaddress
import logging
from pathlib import Path
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, Iterator, Tuple, Callable
from django.db.models import QuerySet
@@ -19,6 +21,14 @@ from apps.common.utils import BlacklistFilter
logger = logging.getLogger(__name__)
class DataSource:
"""数据源类型常量"""
ENDPOINT = "endpoint"
WEBSITE = "website"
HOST_PORT = "host_port"
DEFAULT = "default"
def create_export_service(target_id: int) -> 'TargetExportService':
"""
工厂函数:创建带黑名单过滤的导出服务
@@ -36,21 +46,281 @@ def create_export_service(target_id: int) -> 'TargetExportService':
return TargetExportService(blacklist_filter=blacklist_filter)
def _iter_default_urls_from_target(
target_id: int,
blacklist_filter: Optional[BlacklistFilter] = None
) -> Iterator[str]:
"""
内部生成器:从 Target 本身生成默认 URL
根据 Target 类型生成 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 http(s)://ip
- URL: 直接使用目标 URL
Args:
target_id: 目标 ID
blacklist_filter: 黑名单过滤器
Yields:
str: URL
"""
from apps.targets.services import TargetService
from apps.targets.models import Target
target_service = TargetService()
target = target_service.get_target(target_id)
if not target:
logger.warning("Target ID %d 不存在,无法生成默认 URL", target_id)
return
target_name = target.name
target_type = target.type
# 根据 Target 类型生成 URL
if target_type == Target.TargetType.DOMAIN:
urls = [f"http://{target_name}", f"https://{target_name}"]
elif target_type == Target.TargetType.IP:
urls = [f"http://{target_name}", f"https://{target_name}"]
elif target_type == Target.TargetType.CIDR:
try:
network = ipaddress.ip_network(target_name, strict=False)
urls = []
for ip in network.hosts():
urls.extend([f"http://{ip}", f"https://{ip}"])
# /32 或 /128 特殊处理
if not urls:
ip = str(network.network_address)
urls = [f"http://{ip}", f"https://{ip}"]
except ValueError as e:
logger.error("CIDR 解析失败: %s - %s", target_name, e)
return
elif target_type == Target.TargetType.URL:
urls = [target_name]
else:
logger.warning("不支持的 Target 类型: %s", target_type)
return
# 过滤并产出
for url in urls:
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
yield url
def _iter_urls_with_fallback(
target_id: int,
sources: List[str],
blacklist_filter: Optional[BlacklistFilter] = None,
batch_size: int = 1000,
tried_sources: Optional[List[str]] = None
) -> Iterator[Tuple[str, str]]:
"""
内部生成器:流式产出 URL带回退链
按 sources 顺序尝试每个数据源,直到有数据返回。
回退逻辑:
- 数据源有数据且通过过滤 → 产出 URL停止回退
- 数据源有数据但全被过滤 → 不回退,停止(避免意外暴露)
- 数据源为空 → 继续尝试下一个
Args:
target_id: 目标 ID
sources: 数据源优先级列表
blacklist_filter: 黑名单过滤器
batch_size: 批次大小
tried_sources: 可选,用于记录尝试过的数据源(外部传入列表,会被修改)
Yields:
Tuple[str, str]: (url, source) - URL 和来源标识
"""
from apps.asset.models import Endpoint, WebSite
for source in sources:
if tried_sources is not None:
tried_sources.append(source)
has_output = False # 是否有输出(通过过滤的)
has_raw_data = False # 是否有原始数据(过滤前)
if source == DataSource.DEFAULT:
# 默认 URL 生成(从 Target 本身构造,复用共用生成器)
for url in _iter_default_urls_from_target(target_id, blacklist_filter):
has_raw_data = True
has_output = True
yield url, source
# 检查是否有原始数据(需要单独判断,因为生成器可能被过滤后为空)
if not has_raw_data:
# 再次检查 Target 是否存在
from apps.targets.services import TargetService
target = TargetService().get_target(target_id)
has_raw_data = target is not None
if has_raw_data:
if not has_output:
logger.info("%s 有数据但全被黑名单过滤,不回退", source)
return
continue
# 构建对应数据源的 queryset
if source == DataSource.ENDPOINT:
queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
elif source == DataSource.WEBSITE:
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
else:
logger.warning("未知的数据源类型: %s,跳过", source)
continue
for url in queryset.iterator(chunk_size=batch_size):
if url:
has_raw_data = True
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
has_output = True
yield url, source
# 有原始数据就停止(不管是否被过滤)
if has_raw_data:
if not has_output:
logger.info("%s 有数据但全被黑名单过滤,不回退", source)
return
logger.info("%s 为空,尝试下一个数据源", source)
def get_urls_with_fallback(
target_id: int,
sources: List[str],
batch_size: int = 1000
) -> Dict[str, Any]:
"""
带回退链的 URL 获取用例函数(返回列表)
按 sources 顺序尝试每个数据源,直到有数据返回。
Args:
target_id: 目标 ID
sources: 数据源优先级列表,如 ["website", "endpoint", "default"]
batch_size: 批次大小
Returns:
dict: {
'success': bool,
'urls': List[str],
'total_count': int,
'source': str, # 实际使用的数据源
'tried_sources': List[str], # 尝试过的数据源
}
"""
from apps.common.services import BlacklistService
rules = BlacklistService().get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
urls = []
actual_source = 'none'
tried_sources = []
for url, source in _iter_urls_with_fallback(target_id, sources, blacklist_filter, batch_size, tried_sources):
urls.append(url)
actual_source = source
if urls:
logger.info("%s 获取 %d 条 URL", actual_source, len(urls))
else:
logger.warning("所有数据源都为空,无法获取 URL")
return {
'success': True,
'urls': urls,
'total_count': len(urls),
'source': actual_source,
'tried_sources': tried_sources,
}
def export_urls_with_fallback(
target_id: int,
output_file: str,
sources: List[str],
batch_size: int = 1000
) -> Dict[str, Any]:
"""
带回退链的 URL 导出用例函数(写入文件)
按 sources 顺序尝试每个数据源,直到有数据返回。
流式写入,内存占用 O(1)。
Args:
target_id: 目标 ID
output_file: 输出文件路径
sources: 数据源优先级列表,如 ["endpoint", "website", "default"]
batch_size: 批次大小
Returns:
dict: {
'success': bool,
'output_file': str,
'total_count': int,
'source': str, # 实际使用的数据源
'tried_sources': List[str], # 尝试过的数据源
}
"""
from apps.common.services import BlacklistService
rules = BlacklistService().get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
total_count = 0
actual_source = 'none'
tried_sources = []
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for url, source in _iter_urls_with_fallback(target_id, sources, blacklist_filter, batch_size, tried_sources):
f.write(f"{url}\n")
total_count += 1
actual_source = source
if total_count % 10000 == 0:
logger.info("已导出 %d 个 URL...", total_count)
if total_count > 0:
logger.info("%s 导出 %d 条 URL 到 %s", actual_source, total_count, output_file)
else:
logger.warning("所有数据源都为空,无法导出 URL")
return {
'success': True,
'output_file': str(output_path),
'total_count': total_count,
'source': actual_source,
'tried_sources': tried_sources,
}
class TargetExportService:
"""
目标导出服务 - 提供统一的目标提取和文件导出功能
使用方式:
from apps.common.services import BlacklistService
from apps.common.utils import BlacklistFilter
# 方式 1使用用例函数推荐
from apps.scan.services.target_export_service import export_urls_with_fallback, DataSource
# 获取规则并创建过滤器
blacklist_service = BlacklistService()
rules = blacklist_service.get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
result = export_urls_with_fallback(
target_id=1,
output_file='/path/to/output.txt',
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT]
)
# 使用导出服务
export_service = TargetExportService(blacklist_filter=blacklist_filter)
# 方式 2直接使用 Service纯导出不带回退
export_service = create_export_service(target_id)
result = export_service.export_urls(target_id, output_path, queryset)
"""
@@ -72,16 +342,14 @@ class TargetExportService:
batch_size: int = 1000
) -> Dict[str, Any]:
"""
统一 URL 导出函数
URL 导出函数 - 只负责将 queryset 数据写入文件
自动判断数据库有无数据:
- 有数据:流式写入数据库数据到文件
- 无数据:调用默认值生成器生成 URL
不做任何隐式回退或默认 URL 生成。
Args:
target_id: 目标 ID
output_path: 输出文件路径
queryset: 数据源 queryset Task 层构建,应为 values_list flat=True
queryset: 数据源 queryset调用方构建,应为 values_list flat=True
url_field: URL 字段名(用于黑名单过滤)
batch_size: 批次大小
@@ -89,7 +357,9 @@ class TargetExportService:
dict: {
'success': bool,
'output_file': str,
'total_count': int
'total_count': int, # 实际写入数量
'queryset_count': int, # 原始数据数量(迭代计数)
'filtered_count': int, # 被黑名单过滤的数量
}
Raises:
@@ -102,9 +372,12 @@ class TargetExportService:
total_count = 0
filtered_count = 0
queryset_count = 0
try:
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
for url in queryset.iterator(chunk_size=batch_size):
queryset_count += 1
if url:
# 黑名单过滤
if self.blacklist_filter and not self.blacklist_filter.is_allowed(url):
@@ -122,25 +395,26 @@ class TargetExportService:
if filtered_count > 0:
logger.info("黑名单过滤: 过滤 %d 个 URL", filtered_count)
# 默认值回退模式
if total_count == 0:
total_count = self._generate_default_urls(target_id, output_file)
logger.info("✓ URL 导出完成 - 数量: %d, 文件: %s", total_count, output_path)
logger.info(
"✓ URL 导出完成 - 写入: %d, 原始: %d, 过滤: %d, 文件: %s",
total_count, queryset_count, filtered_count, output_path
)
return {
'success': True,
'output_file': str(output_file),
'total_count': total_count
'total_count': total_count,
'queryset_count': queryset_count,
'filtered_count': filtered_count,
}
def _generate_default_urls(
def generate_default_urls(
self,
target_id: int,
output_path: Path
) -> int:
output_path: str
) -> Dict[str, Any]:
"""
默认值生成器(内部函数)
默认 URL 生成器
根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
@@ -153,82 +427,34 @@ class TargetExportService:
output_path: 输出文件路径
Returns:
int: 写入的 URL 总数
dict: {
'success': bool,
'output_file': str,
'total_count': int,
}
"""
from apps.targets.services import TargetService
from apps.targets.models import Target
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
target_service = TargetService()
target = target_service.get_target(target_id)
if not target:
logger.warning("Target ID %d 不存在,无法生成默认 URL", target_id)
return 0
target_name = target.name
target_type = target.type
logger.info("懒加载模式Target 类型=%s, 名称=%s", target_type, target_name)
logger.info("生成默认 URL - target_id=%d", target_id)
total_urls = 0
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
if target_type == Target.TargetType.DOMAIN:
urls = [f"http://{target_name}", f"https://{target_name}"]
for url in urls:
if self._should_write_url(url):
f.write(f"{url}\n")
total_urls += 1
elif target_type == Target.TargetType.IP:
urls = [f"http://{target_name}", f"https://{target_name}"]
for url in urls:
if self._should_write_url(url):
f.write(f"{url}\n")
total_urls += 1
elif target_type == Target.TargetType.CIDR:
try:
network = ipaddress.ip_network(target_name, strict=False)
for ip in network.hosts():
urls = [f"http://{ip}", f"https://{ip}"]
for url in urls:
if self._should_write_url(url):
f.write(f"{url}\n")
total_urls += 1
if total_urls % 10000 == 0:
logger.info("已生成 %d 个 URL...", total_urls)
# /32 或 /128 特殊处理
if total_urls == 0:
ip = str(network.network_address)
urls = [f"http://{ip}", f"https://{ip}"]
for url in urls:
if self._should_write_url(url):
f.write(f"{url}\n")
total_urls += 1
except ValueError as e:
logger.error("CIDR 解析失败: %s - %s", target_name, e)
raise ValueError(f"无效的 CIDR: {target_name}") from e
elif target_type == Target.TargetType.URL:
if self._should_write_url(target_name):
f.write(f"{target_name}\n")
total_urls = 1
else:
logger.warning("不支持的 Target 类型: %s", target_type)
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
for url in _iter_default_urls_from_target(target_id, self.blacklist_filter):
f.write(f"{url}\n")
total_urls += 1
if total_urls % 10000 == 0:
logger.info("已生成 %d 个 URL...", total_urls)
logger.info("懒加载生成默认 URL - 数量: %d", total_urls)
return total_urls
def _should_write_url(self, url: str) -> bool:
"""检查 URL 是否应该写入(通过黑名单过滤)"""
if self.blacklist_filter:
return self.blacklist_filter.is_allowed(url)
return True
logger.info("✓ 默认 URL 生成完成 - 数量: %d", total_urls)
return {
'success': True,
'output_file': str(output_file),
'total_count': total_urls,
}
def export_hosts(
self,

View File

@@ -1,15 +1,16 @@
"""
导出站点 URL 到 TXT 文件的 Task
使用 TargetExportService 统一处理导出逻辑和默认值回退
数据源: WebSite.url
使用 export_urls_with_fallback 用例函数处理回退链逻辑
数据源: WebSite.url → Default
"""
import logging
from prefect import task
from apps.asset.models import WebSite
from apps.scan.services import TargetExportService
from apps.scan.services.target_export_service import create_export_service
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
@@ -23,13 +24,9 @@ def export_sites_task(
"""
导出目标下的所有站点 URL 到 TXT 文件
数据源: WebSite.url
懒加载模式:
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
数据源优先级(回退链):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID
@@ -47,25 +44,21 @@ def export_sites_task(
ValueError: 参数错误
IOError: 文件写入失败
"""
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
result = export_urls_with_fallback(
target_id=target_id,
output_path=output_file,
queryset=queryset,
batch_size=batch_size
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"站点 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 保持返回值格式不变(向后兼容)
return {
'success': result['success'],
'output_file': result['output_file'],
'total_count': result['total_count']
'total_count': result['total_count'],
}

View File

@@ -2,15 +2,17 @@
导出 URL 任务
用于指纹识别前导出目标下的 URL 到文件
使用 TargetExportService 统一处理导出逻辑和默认值回退
使用 export_urls_with_fallback 用例函数处理回退链逻辑
"""
import logging
from prefect import task
from apps.asset.models import WebSite
from apps.scan.services.target_export_service import create_export_service
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
@@ -19,46 +21,40 @@ logger = logging.getLogger(__name__)
def export_urls_for_fingerprint_task(
target_id: int,
output_file: str,
source: str = 'website',
source: str = 'website', # 保留参数,兼容旧调用(实际值由回退链决定)
batch_size: int = 1000
) -> dict:
"""
导出目标下的 URL 到文件(用于指纹识别)
数据源: WebSite.url
懒加载模式:
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
- URL: 直接使用目标 URL
数据源优先级(回退链):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID
output_file: 输出文件路径
source: 数据源类型(保留参数,兼容旧调用)
source: 数据源类型(保留参数,兼容旧调用,实际值由回退链决定
batch_size: 批量读取大小
Returns:
dict: {'output_file': str, 'total_count': int, 'source': str}
"""
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
result = export_urls_with_fallback(
target_id=target_id,
output_path=output_file,
queryset=queryset,
batch_size=batch_size
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
# 保持返回值格式不变(向后兼容)
logger.info(
"指纹识别 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 返回实际使用的数据源(不再固定为 "website"
return {
'output_file': result['output_file'],
'total_count': result['total_count'],
'source': source
'source': result['source'],
}

View File

@@ -0,0 +1,12 @@
"""
截图任务模块
包含截图相关的所有任务:
- capture_screenshots_task: 批量截图任务
"""
from .capture_screenshots_task import capture_screenshots_task
__all__ = [
'capture_screenshots_task',
]

View File

@@ -0,0 +1,194 @@
"""
批量截图任务
使用 Playwright 批量捕获网站截图,压缩后保存到数据库
"""
import asyncio
import logging
import time
from prefect import task
logger = logging.getLogger(__name__)
def _run_async(coro):
"""
在同步环境中运行异步协程
Args:
coro: 异步协程
Returns:
协程执行结果
"""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def _save_screenshot_with_retry(
screenshot_service,
scan_id: int,
url: str,
webp_data: bytes,
status_code: int | None = None,
max_retries: int = 3
) -> bool:
"""
保存截图到数据库(带重试机制)
Args:
screenshot_service: ScreenshotService 实例
scan_id: 扫描 ID
url: URL
webp_data: WebP 图片数据
status_code: HTTP 响应状态码
max_retries: 最大重试次数
Returns:
是否保存成功
"""
for attempt in range(max_retries):
try:
if screenshot_service.save_screenshot_snapshot(scan_id, url, webp_data, status_code):
return True
# save 返回 False等待后重试
if attempt < max_retries - 1:
wait_time = 2 ** attempt # 指数退避1s, 2s, 4s
logger.warning(
"保存截图失败(第 %d 次尝试),%d秒后重试: %s",
attempt + 1, wait_time, url
)
time.sleep(wait_time)
except Exception as e:
if attempt < max_retries - 1:
wait_time = 2 ** attempt
logger.warning(
"保存截图异常(第 %d 次尝试),%d秒后重试: %s, 错误: %s",
attempt + 1, wait_time, url, str(e)[:100]
)
time.sleep(wait_time)
else:
logger.error("保存截图失败(已重试 %d 次): %s", max_retries, url)
return False
async def _capture_and_save_screenshots(
urls: list[str],
scan_id: int,
concurrency: int
) -> dict:
"""
异步批量截图并保存
Args:
urls: URL 列表
scan_id: 扫描 ID
concurrency: 并发数
Returns:
统计信息字典
"""
from asgiref.sync import sync_to_async
from apps.asset.services.playwright_screenshot_service import PlaywrightScreenshotService
from apps.asset.services.screenshot_service import ScreenshotService
# 初始化服务
playwright_service = PlaywrightScreenshotService(concurrency=concurrency)
screenshot_service = ScreenshotService()
# 包装同步的保存函数为异步
async_save_with_retry = sync_to_async(_save_screenshot_with_retry, thread_sensitive=True)
# 统计
total = len(urls)
successful = 0
failed = 0
logger.info("开始批量截图 - URL数: %d, 并发数: %d", total, concurrency)
# 批量截图
async for url, screenshot_bytes, status_code in playwright_service.capture_batch(urls):
if screenshot_bytes is None:
failed += 1
continue
# 压缩为 WebP
webp_data = screenshot_service.compress_from_bytes(screenshot_bytes)
if webp_data is None:
logger.warning("压缩截图失败: %s", url)
failed += 1
continue
# 保存到数据库(带重试,使用 sync_to_async
if await async_save_with_retry(screenshot_service, scan_id, url, webp_data, status_code):
successful += 1
if successful % 10 == 0:
logger.info("截图进度: %d/%d 成功", successful, total)
else:
failed += 1
return {
'total': total,
'successful': successful,
'failed': failed
}
@task(name='capture_screenshots', retries=0)
def capture_screenshots_task(
urls: list[str],
scan_id: int,
target_id: int,
config: dict
) -> dict:
"""
批量截图任务
Args:
urls: URL 列表
scan_id: 扫描 ID
target_id: 目标 ID用于日志
config: 截图配置
- concurrency: 并发数(默认 5
Returns:
dict: {
'total': int, # 总 URL 数
'successful': int, # 成功截图数
'failed': int # 失败数
}
"""
if not urls:
logger.info("URL 列表为空,跳过截图任务")
return {'total': 0, 'successful': 0, 'failed': 0}
concurrency = config.get('concurrency', 5)
logger.info(
"开始截图任务 - scan_id=%d, target_id=%d, URL数=%d, 并发=%d",
scan_id, target_id, len(urls), concurrency
)
try:
result = _run_async(_capture_and_save_screenshots(
urls=urls,
scan_id=scan_id,
concurrency=concurrency
))
logger.info(
"✓ 截图任务完成 - 总数: %d, 成功: %d, 失败: %d",
result['total'], result['successful'], result['failed']
)
return result
except Exception as e:
logger.error("截图任务失败: %s", e, exc_info=True)
raise RuntimeError(f"截图任务失败: {e}") from e

View File

@@ -2,7 +2,7 @@
导出站点URL到文件的Task
直接使用 HostPortMapping 表查询 host+port 组合拼接成URL格式写入文件
使用 TargetExportService 处理默认值回退逻辑
使用 TargetExportService.generate_default_urls() 处理默认值回退逻辑
特殊逻辑:
- 80 端口:只生成 HTTP URL省略端口号
@@ -46,18 +46,15 @@ def export_site_urls_task(
"""
导出目标下的所有站点URL到文件基于 HostPortMapping 表)
数据源: HostPortMapping (host + port)
数据源: HostPortMapping (host + port) → Default
特殊逻辑:
- 80 端口:只生成 HTTP URL省略端口号
- 443 端口:只生成 HTTPS URL省略端口号
- 其他端口:生成 HTTP 和 HTTPS 两个URL带端口号
懒加载模式
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
回退逻辑
- 如果 HostPortMapping 为空,使用 generate_default_urls() 生成默认 URL
Args:
target_id: 目标ID
@@ -69,7 +66,8 @@ def export_site_urls_task(
'success': bool,
'output_file': str,
'total_urls': int,
'association_count': int # 主机端口关联数量
'association_count': int, # 主机端口关联数量
'source': str, # 数据来源: "host_port" | "default"
}
Raises:
@@ -94,6 +92,7 @@ def export_site_urls_task(
total_urls = 0
association_count = 0
filtered_count = 0
# 流式写入文件(特殊端口逻辑)
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
@@ -104,6 +103,7 @@ def export_site_urls_task(
# 先校验 host通过了再生成 URL
if not blacklist_filter.is_allowed(host):
filtered_count += 1
continue
# 根据端口号生成URL
@@ -114,19 +114,40 @@ def export_site_urls_task(
if association_count % 1000 == 0:
logger.info("已处理 %d 条关联,生成 %d 个URL...", association_count, total_urls)
if filtered_count > 0:
logger.info("黑名单过滤: 过滤 %d 条关联", filtered_count)
logger.info(
"✓ 站点URL导出完成 - 关联数: %d, 总URL数: %d, 文件: %s",
association_count, total_urls, str(output_path)
)
# 默认值回退模式:使用工厂函数创建导出服务
# 判断数据来源
source = "host_port"
# 数据存在但全被过滤,不回退
if association_count > 0 and total_urls == 0:
logger.info("HostPortMapping 有 %d 条数据,但全被黑名单过滤,不回退", association_count)
return {
'success': True,
'output_file': str(output_path),
'total_urls': 0,
'association_count': association_count,
'source': source,
}
# 数据源为空,回退到默认 URL 生成
if total_urls == 0:
logger.info("HostPortMapping 为空,使用默认 URL 生成")
export_service = create_export_service(target_id)
total_urls = export_service._generate_default_urls(target_id, output_path)
result = export_service.generate_default_urls(target_id, str(output_path))
total_urls = result['total_count']
source = "default"
return {
'success': True,
'output_file': str(output_path),
'total_urls': total_urls,
'association_count': association_count
'association_count': association_count,
'source': source,
}

View File

@@ -341,11 +341,12 @@ def _save_batch(
)
snapshot_items.append(snapshot_dto)
except Exception as e:
logger.error("处理记录失败: %s,错误: %s", record.url, e)
continue
# ========== Step 3: 保存快照并同步到资产表(通过快照 Service==========
# ========== Step 2: 保存快照并同步到资产表(通过快照 Service==========
if snapshot_items:
services.snapshot.save_and_sync(snapshot_items)

View File

@@ -1,16 +1,17 @@
"""
导出站点 URL 列表任务
使用 TargetExportService 统一处理导出逻辑和默认值回退
数据源: WebSite.url用于 katana 等爬虫工具)
使用 export_urls_with_fallback 用例函数处理回退链逻辑
数据源: WebSite.url → Default(用于 katana 等爬虫工具)
"""
import logging
from prefect import task
from typing import Optional
from apps.asset.models import WebSite
from apps.scan.services.target_export_service import create_export_service
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
@@ -29,13 +30,9 @@ def export_sites_task(
"""
导出站点 URL 列表到文件(用于 katana 等爬虫工具)
数据源: WebSite.url
懒加载模式:
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
数据源优先级(回退链):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
output_file: 输出文件路径
@@ -53,17 +50,16 @@ def export_sites_task(
ValueError: 参数错误
RuntimeError: 执行失败
"""
# 构建数据源 querysetTask 层决定数据源)
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
result = export_urls_with_fallback(
target_id=target_id,
output_path=output_file,
queryset=queryset,
batch_size=batch_size
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"站点 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 保持返回值格式不变(向后兼容)

View File

@@ -1,16 +1,22 @@
"""导出 Endpoint URL 到文件的 Task
使用 TargetExportService 统一处理导出逻辑和默认值回退
数据源: Endpoint.url
使用 export_urls_with_fallback 用例函数处理回退链逻辑
数据源优先级(回退链):
1. Endpoint.url - 最精细的 URL含路径、参数等
2. WebSite.url - 站点级别 URL
3. 默认生成 - 根据 Target 类型生成 http(s)://target_name
"""
import logging
from typing import Dict, Optional
from typing import Dict
from prefect import task
from apps.asset.models import Endpoint
from apps.scan.services.target_export_service import create_export_service
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
logger = logging.getLogger(__name__)
@@ -23,13 +29,10 @@ def export_endpoints_task(
) -> Dict[str, object]:
"""导出目标下的所有 Endpoint URL 到文本文件。
数据源: Endpoint.url
懒加载模式:
- 如果数据库为空,根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 URL
数据源优先级(回退链):
1. Endpoint 表 - 最精细的 URL含路径、参数等
2. WebSite 表 - 站点级别 URL
3. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID
@@ -41,24 +44,24 @@ def export_endpoints_task(
"success": bool,
"output_file": str,
"total_count": int,
"source": str, # 数据来源: "endpoint" | "website" | "default" | "none"
}
"""
# 构建数据源 querysetTask 层决定数据源)
queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
# 使用工厂函数创建导出服务
export_service = create_export_service(target_id)
result = export_service.export_urls(
result = export_urls_with_fallback(
target_id=target_id,
output_path=output_file,
queryset=queryset,
batch_size=batch_size
output_file=output_file,
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"URL 导出完成 - source=%s, count=%d, tried=%s",
result['source'], result['total_count'], result['tried_sources']
)
# 保持返回值格式不变(向后兼容)
return {
"success": result['success'],
"output_file": result['output_file'],
"total_count": result['total_count'],
"source": result['source'],
}

View File

@@ -4,7 +4,8 @@ from .views import ScanViewSet, ScheduledScanViewSet, ScanLogListView, Subfinder
from .notifications.views import notification_callback
from apps.asset.views import (
SubdomainSnapshotViewSet, WebsiteSnapshotViewSet, DirectorySnapshotViewSet,
EndpointSnapshotViewSet, HostPortMappingSnapshotViewSet, VulnerabilitySnapshotViewSet
EndpointSnapshotViewSet, HostPortMappingSnapshotViewSet, VulnerabilitySnapshotViewSet,
ScreenshotSnapshotViewSet
)
# 创建路由器
@@ -26,6 +27,8 @@ scan_endpoints_export = EndpointSnapshotViewSet.as_view({'get': 'export'})
scan_ip_addresses_list = HostPortMappingSnapshotViewSet.as_view({'get': 'list'})
scan_ip_addresses_export = HostPortMappingSnapshotViewSet.as_view({'get': 'export'})
scan_vulnerabilities_list = VulnerabilitySnapshotViewSet.as_view({'get': 'list'})
scan_screenshots_list = ScreenshotSnapshotViewSet.as_view({'get': 'list'})
scan_screenshots_image = ScreenshotSnapshotViewSet.as_view({'get': 'image'})
urlpatterns = [
path('', include(router.urls)),
@@ -47,5 +50,7 @@ urlpatterns = [
path('scans/<int:scan_pk>/ip-addresses/', scan_ip_addresses_list, name='scan-ip-addresses-list'),
path('scans/<int:scan_pk>/ip-addresses/export/', scan_ip_addresses_export, name='scan-ip-addresses-export'),
path('scans/<int:scan_pk>/vulnerabilities/', scan_vulnerabilities_list, name='scan-vulnerabilities-list'),
path('scans/<int:scan_pk>/screenshots/', scan_screenshots_list, name='scan-screenshots-list'),
path('scans/<int:scan_pk>/screenshots/<int:pk>/image/', scan_screenshots_image, name='scan-screenshots-image'),
]

View File

@@ -119,6 +119,7 @@ class TargetDetailSerializer(serializers.ModelSerializer):
- endpoints: 端点数量
- ips: IP地址数量
- directories: 目录数量
- screenshots: 截图数量
- vulnerabilities: 漏洞统计(暂时返回 0待后续实现
性能说明:
@@ -134,6 +135,7 @@ class TargetDetailSerializer(serializers.ModelSerializer):
endpoints_count = obj.endpoints.count()
ips_count = obj.host_port_mappings.values('ip').distinct().count()
directories_count = obj.directories.count()
screenshots_count = obj.screenshots.count()
# 漏洞统计:按目标维度实时统计 Vulnerability 资产表
vuln_qs = obj.vulnerabilities.all()
@@ -159,6 +161,7 @@ class TargetDetailSerializer(serializers.ModelSerializer):
'endpoints': endpoints_count,
'ips': ips_count,
'directories': directories_count,
'screenshots': screenshots_count,
'vulnerabilities': {
'total': total,
**severity_stats,
@@ -182,12 +185,12 @@ class BatchCreateTargetSerializer(serializers.Serializer):
批量创建目标的序列化器
安全限制:
- 最多支持 1000 个目标的批量创建
- 最多支持 5000 个目标的批量创建
- 防止恶意用户提交大量数据导致服务器过载
"""
# 批量创建的最大数量限制
MAX_BATCH_SIZE = 1000
MAX_BATCH_SIZE = 5000
# 目标列表
targets = serializers.ListField(

View File

@@ -3,7 +3,8 @@ from rest_framework.routers import DefaultRouter
from .views import OrganizationViewSet, TargetViewSet
from apps.asset.views import (
SubdomainViewSet, WebSiteViewSet, DirectoryViewSet,
EndpointViewSet, HostPortMappingViewSet, VulnerabilityViewSet
EndpointViewSet, HostPortMappingViewSet, VulnerabilityViewSet,
ScreenshotViewSet
)
# 创建路由器
@@ -29,6 +30,8 @@ target_endpoints_bulk_create = EndpointViewSet.as_view({'post': 'bulk_create'})
target_ip_addresses_list = HostPortMappingViewSet.as_view({'get': 'list'})
target_ip_addresses_export = HostPortMappingViewSet.as_view({'get': 'export'})
target_vulnerabilities_list = VulnerabilityViewSet.as_view({'get': 'list'})
target_screenshots_list = ScreenshotViewSet.as_view({'get': 'list'})
target_screenshots_bulk_delete = ScreenshotViewSet.as_view({'post': 'bulk_delete'})
urlpatterns = [
path('', include(router.urls)),
@@ -48,4 +51,6 @@ urlpatterns = [
path('targets/<int:target_pk>/ip-addresses/', target_ip_addresses_list, name='target-ip-addresses-list'),
path('targets/<int:target_pk>/ip-addresses/export/', target_ip_addresses_export, name='target-ip-addresses-export'),
path('targets/<int:target_pk>/vulnerabilities/', target_vulnerabilities_list, name='target-vulnerabilities-list'),
path('targets/<int:target_pk>/screenshots/', target_screenshots_list, name='target-screenshots-list'),
path('targets/<int:target_pk>/screenshots/bulk-delete/', target_screenshots_bulk_delete, name='target-screenshots-bulk-delete'),
]

View File

@@ -41,6 +41,8 @@ pytest-django==4.7.0
# 工具库
python-dateutil==2.9.0
Pillow>=10.0.0 # 图像处理(截图服务)
playwright>=1.40.0 # 浏览器自动化(截图服务)
pytz==2024.1
validators==0.22.0
PyYAML==6.0.1

View File

@@ -639,19 +639,19 @@ class TestDataGenerator:
target_id, engine_ids, engine_names, yaml_configuration, status, worker_id, progress, current_stage,
results_dir, error_message, container_ids, stage_progress,
cached_subdomains_count, cached_websites_count, cached_endpoints_count,
cached_ips_count, cached_directories_count, cached_vulns_total,
cached_ips_count, cached_directories_count, cached_screenshots_count, cached_vulns_total,
cached_vulns_critical, cached_vulns_high, cached_vulns_medium, cached_vulns_low,
created_at, stopped_at, deleted_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
NOW() - INTERVAL '%s days', %s, NULL
)
RETURNING id
""", (
target_id, selected_engine_ids, json.dumps(selected_engine_names), '', status, worker_id, progress, stage,
f'/app/results/scan_{target_id}_{random.randint(1000, 9999)}', error_msg, '{}', '{}',
subdomains, websites, endpoints, ips, directories, vulns_total,
subdomains, websites, endpoints, ips, directories, 0, 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

View File

@@ -2,7 +2,7 @@
# ============================================
# XingRin 远程节点安装脚本
# 用途:安装 Docker 环境 + 预拉取镜像
# 支持Ubuntu / Debian
# 支持Ubuntu / Debian / Kali
#
# 架构说明:
# 1. 安装 Docker 环境
@@ -101,8 +101,8 @@ detect_os() {
exit 1
fi
if [[ "$OS" != "ubuntu" && "$OS" != "debian" ]]; then
log_error "仅支持 Ubuntu/Debian 系统"
if [[ "$OS" != "ubuntu" && "$OS" != "debian" && "$OS" != "kali" ]]; then
log_error "仅支持 Ubuntu/Debian/Kali 系统"
exit 1
fi
}

View File

@@ -17,7 +17,8 @@
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DOCKER_DIR="$(dirname "$SCRIPT_DIR")"
cd "$DOCKER_DIR"
# 颜色输出
GREEN='\033[0;32m'
@@ -33,7 +34,9 @@ log_step() { echo -e " ${CYAN}>>${NC} $1"; }
# 检查服务是否运行
check_server() {
if ! docker compose ps --status running 2>/dev/null | grep -q "server"; then
# 使用 docker compose ps 的 --format 选项获取服务状态
# 这种方式不依赖容器名称格式,只检查服务名
if ! docker compose ps --format '{{.Service}} {{.State}}' 2>/dev/null | grep -E "^server\s+running" > /dev/null; then
echo "Server 容器未运行,跳过数据初始化"
return 1
fi

View File

@@ -45,7 +45,9 @@ ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
# 1. 安装基础工具和 Python
RUN apt-get update && apt-get install -y \
# 注意ARM64 使用 ports.ubuntu.com可能存在镜像同步延迟需要重试机制
RUN apt-get update && \
apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-venv \
@@ -60,8 +62,32 @@ RUN apt-get update && apt-get install -y \
masscan \
libpcap-dev \
ca-certificates \
fonts-liberation \
libnss3 \
libxss1 \
libasound2t64 \
|| (rm -rf /var/lib/apt/lists/* && apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv pipx git curl wget unzip jq tmux nmap masscan libpcap-dev \
ca-certificates fonts-liberation libnss3 libxss1 libasound2t64) \
&& rm -rf /var/lib/apt/lists/*
# 安装 Chromium通过 Playwright 安装,支持 ARM64 和 AMD64
# Ubuntu 24.04 的 chromium-browser 是 snap 过渡包Docker 中不可用
RUN pip install playwright --break-system-packages && \
playwright install chromium && \
apt-get update && \
playwright install-deps chromium && \
rm -rf /var/lib/apt/lists/*
# 设置 Chrome 路径供 httpx 等工具使用Playwright 安装位置)
ENV CHROME_PATH=/root/.cache/ms-playwright/chromium-*/chrome-linux/chrome
# 创建软链接确保 httpx 的 -system-chrome 能找到浏览器
RUN CHROME_BIN=$(find /root/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1) && \
ln -sf "$CHROME_BIN" /usr/bin/chromium-browser && \
ln -sf "$CHROME_BIN" /usr/bin/chromium && \
ln -sf "$CHROME_BIN" /usr/bin/chrome && \
ln -sf "$CHROME_BIN" /usr/bin/google-chrome-stable
# 建立 python 软链接
RUN ln -s /usr/bin/python3 /usr/bin/python

View File

@@ -54,10 +54,10 @@ flowchart TB
TARGET --> SUBLIST3R
TARGET --> ASSETFINDER
subgraph STAGE2["Stage 2: Analysis Parallel"]
subgraph STAGE2["Stage 2: URL Collection Parallel"]
direction TB
subgraph URL["URL Collection"]
subgraph URL["URL Fetch"]
direction TB
WAYMORE[waymore<br/>Historical URLs]
KATANA[katana<br/>Crawler]
@@ -78,7 +78,15 @@ flowchart TB
XINGFINGER --> KATANA
XINGFINGER --> FFUF
subgraph STAGE3["Stage 3: Vulnerability Sequential"]
subgraph STAGE3["Stage 3: Screenshot Sequential"]
direction TB
SCREENSHOT[Playwright<br/>Page Screenshot]
end
HTTPX2 --> SCREENSHOT
FFUF --> SCREENSHOT
subgraph STAGE4["Stage 4: Vulnerability Sequential"]
direction TB
subgraph VULN["Vulnerability Scan"]
@@ -88,12 +96,11 @@ flowchart TB
end
end
HTTPX2 --> DALFOX
HTTPX2 --> NUCLEI
SCREENSHOT --> DALFOX
SCREENSHOT --> NUCLEI
DALFOX --> FINISH
NUCLEI --> FINISH
FFUF --> FINISH
FINISH[Scan Complete]
@@ -109,9 +116,14 @@ flowchart TB
```python
# backend/apps/scan/configs/command_templates.py
# Stage 1: 资产发现 - 子域名 → 端口 → 站点探测 → 指纹识别
# Stage 2: URL 收集 - URL 获取 + 目录扫描(并行)
# Stage 3: 截图 - 在 URL 收集完成后执行,捕获更多发现的页面
# Stage 4: 漏洞扫描 - 最后执行
EXECUTION_STAGES = [
{'mode': 'sequential', 'flows': ['subdomain_discovery', 'port_scan', 'site_scan', 'fingerprint_detect']},
{'mode': 'parallel', 'flows': ['url_fetch', 'directory_scan']},
{'mode': 'sequential', 'flows': ['screenshot']},
{'mode': 'sequential', 'flows': ['vuln_scan']},
]
```
@@ -126,4 +138,5 @@ EXECUTION_STAGES = [
| fingerprint_detect | xingfinger | WebSite.tech更新 |
| url_fetch | waymore, katana, uro, httpx | Endpoint |
| directory_scan | ffuf | Directory |
| screenshot | Playwright | Screenshot |
| vuln_scan | dalfox, nuclei | Vulnerability |

BIN
docs/wx_pay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
docs/zfb_pay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -34,6 +34,7 @@ const FEATURE_LIST = [
{ key: "site_scan" },
{ key: "fingerprint_detect" },
{ key: "directory_scan" },
{ key: "screenshot" },
{ key: "url_fetch" },
{ key: "vuln_scan" },
] as const
@@ -48,6 +49,7 @@ function parseEngineFeatures(engine: ScanEngine): Record<FeatureKey, boolean> {
site_scan: false,
fingerprint_detect: false,
directory_scan: false,
screenshot: false,
url_fetch: false,
vuln_scan: false,
}
@@ -64,6 +66,7 @@ function parseEngineFeatures(engine: ScanEngine): Record<FeatureKey, boolean> {
site_scan: !!config.site_scan,
fingerprint_detect: !!config.fingerprint_detect,
directory_scan: !!config.directory_scan,
screenshot: !!config.screenshot,
url_fetch: !!config.url_fetch,
vuln_scan: !!config.vuln_scan,
}

View File

@@ -3,9 +3,10 @@
import React from "react"
import { usePathname, useParams } from "next/navigation"
import Link from "next/link"
import { Target } from "lucide-react"
import { Target, LayoutDashboard, Package, Image, ShieldAlert } from "lucide-react"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { useScan } from "@/hooks/use-scans"
import { useTranslations } from "next-intl"
@@ -19,104 +20,136 @@ export default function ScanHistoryLayout({
const { data: scanData, isLoading } = useScan(parseInt(id))
const t = useTranslations("scan.history")
const getActiveTab = () => {
if (pathname.includes("/subdomain")) return "subdomain"
if (pathname.includes("/endpoints")) return "endpoints"
if (pathname.includes("/websites")) return "websites"
if (pathname.includes("/directories")) return "directories"
// Get primary navigation active tab
const getPrimaryTab = () => {
if (pathname.includes("/overview")) return "overview"
if (pathname.includes("/screenshots")) return "screenshots"
if (pathname.includes("/vulnerabilities")) return "vulnerabilities"
if (pathname.includes("/ip-addresses")) return "ip-addresses"
return ""
// All asset pages fall under "assets"
if (
pathname.includes("/websites") ||
pathname.includes("/subdomain") ||
pathname.includes("/ip-addresses") ||
pathname.includes("/endpoints") ||
pathname.includes("/directories")
) {
return "assets"
}
return "overview"
}
// Get secondary navigation active tab (for assets)
const getSecondaryTab = () => {
if (pathname.includes("/websites")) return "websites"
if (pathname.includes("/subdomain")) return "subdomain"
if (pathname.includes("/ip-addresses")) return "ip-addresses"
if (pathname.includes("/endpoints")) return "endpoints"
if (pathname.includes("/directories")) return "directories"
return "websites"
}
// Check if we should show secondary navigation
const showSecondaryNav = getPrimaryTab() === "assets"
const basePath = `/scan/history/${id}`
const tabPaths = {
subdomain: `${basePath}/subdomain/`,
endpoints: `${basePath}/endpoints/`,
websites: `${basePath}/websites/`,
directories: `${basePath}/directories/`,
const primaryPaths = {
overview: `${basePath}/overview/`,
assets: `${basePath}/websites/`, // Default to websites when clicking assets
screenshots: `${basePath}/screenshots/`,
vulnerabilities: `${basePath}/vulnerabilities/`,
}
const secondaryPaths = {
websites: `${basePath}/websites/`,
subdomain: `${basePath}/subdomain/`,
"ip-addresses": `${basePath}/ip-addresses/`,
endpoints: `${basePath}/endpoints/`,
directories: `${basePath}/directories/`,
}
// Get counts for each tab from scan data
const summary = scanData?.summary as any
const counts = {
subdomain: scanData?.summary?.subdomains || 0,
endpoints: scanData?.summary?.endpoints || 0,
websites: scanData?.summary?.websites || 0,
directories: scanData?.summary?.directories || 0,
vulnerabilities: scanData?.summary?.vulnerabilities?.total || 0,
"ip-addresses": scanData?.summary?.ips || 0,
subdomain: summary?.subdomains || 0,
endpoints: summary?.endpoints || 0,
websites: summary?.websites || 0,
directories: summary?.directories || 0,
screenshots: summary?.screenshots || 0,
vulnerabilities: summary?.vulnerabilities?.total || 0,
"ip-addresses": summary?.ips || 0,
}
// Calculate total assets count
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints + counts.directories
// Loading state
if (isLoading) {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* Header skeleton */}
<div className="flex items-center gap-2 px-4 lg:px-6">
<Skeleton className="h-4 w-16" />
<span className="text-muted-foreground">/</span>
<Skeleton className="h-4 w-32" />
</div>
{/* Tabs skeleton */}
<div className="flex gap-1 px-4 lg:px-6">
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-24" />
</div>
</div>
)
}
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Target />
Scan Results
</h2>
<p className="text-muted-foreground">{t("taskId", { id })}</p>
</div>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 h-full">
{/* Header: Page label + Scan info */}
<div className="flex items-center gap-2 text-sm px-4 lg:px-6">
<span className="text-muted-foreground">{t("breadcrumb.scanHistory")}</span>
<span className="text-muted-foreground">/</span>
<span className="font-medium flex items-center gap-1.5">
<Target className="h-4 w-4" />
{(scanData?.target as any)?.name || t("taskId", { id })}
</span>
</div>
<div className="flex items-center justify-between px-4 lg:px-6">
<Tabs value={getActiveTab()} className="w-full">
{/* Primary navigation */}
<div className="px-4 lg:px-6">
<Tabs value={getPrimaryTab()}>
<TabsList>
<TabsTrigger value="websites" asChild>
<Link href={tabPaths.websites} className="flex items-center gap-0.5">
Websites
{counts.websites > 0 && (
<TabsTrigger value="overview" asChild>
<Link href={primaryPaths.overview} className="flex items-center gap-1.5">
<LayoutDashboard className="h-4 w-4" />
{t("tabs.overview")}
</Link>
</TabsTrigger>
<TabsTrigger value="assets" asChild>
<Link href={primaryPaths.assets} className="flex items-center gap-1.5">
<Package className="h-4 w-4" />
{t("tabs.assets")}
{totalAssets > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.websites}
{totalAssets}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="subdomain" asChild>
<Link href={tabPaths.subdomain} className="flex items-center gap-0.5">
Subdomains
{counts.subdomain > 0 && (
<TabsTrigger value="screenshots" asChild>
<Link href={primaryPaths.screenshots} className="flex items-center gap-1.5">
<Image className="h-4 w-4" />
{t("tabs.screenshots")}
{counts.screenshots > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.subdomain}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="ip-addresses" asChild>
<Link href={tabPaths["ip-addresses"]} className="flex items-center gap-0.5">
IP Addresses
{counts["ip-addresses"] > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts["ip-addresses"]}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="endpoints" asChild>
<Link href={tabPaths.endpoints} className="flex items-center gap-0.5">
URLs
{counts.endpoints > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.endpoints}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="directories" asChild>
<Link href={tabPaths.directories} className="flex items-center gap-0.5">
Directories
{counts.directories > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.directories}
{counts.screenshots}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="vulnerabilities" asChild>
<Link href={tabPaths.vulnerabilities} className="flex items-center gap-0.5">
Vulnerabilities
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-1.5">
<ShieldAlert className="h-4 w-4" />
{t("tabs.vulnerabilities")}
{counts.vulnerabilities > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.vulnerabilities}
@@ -128,6 +161,67 @@ export default function ScanHistoryLayout({
</Tabs>
</div>
{/* Secondary navigation (only for assets) */}
{showSecondaryNav && (
<div className="flex items-center px-4 lg:px-6">
<Tabs value={getSecondaryTab()} className="w-full">
<TabsList variant="underline">
<TabsTrigger value="websites" variant="underline" asChild>
<Link href={secondaryPaths.websites} className="flex items-center gap-0.5">
Websites
{counts.websites > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.websites}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="subdomain" variant="underline" asChild>
<Link href={secondaryPaths.subdomain} className="flex items-center gap-0.5">
Subdomains
{counts.subdomain > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.subdomain}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="ip-addresses" variant="underline" asChild>
<Link href={secondaryPaths["ip-addresses"]} className="flex items-center gap-0.5">
IPs
{counts["ip-addresses"] > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts["ip-addresses"]}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="endpoints" variant="underline" asChild>
<Link href={secondaryPaths.endpoints} className="flex items-center gap-0.5">
URLs
{counts.endpoints > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.endpoints}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="directories" variant="underline" asChild>
<Link href={secondaryPaths.directories} className="flex items-center gap-0.5">
Directories
{counts.directories > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.directories}
</Badge>
)}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{/* Sub-page content */}
{children}
</div>
)

View File

@@ -0,0 +1,19 @@
"use client"
import { useParams } from "next/navigation"
import { ScanOverview } from "@/components/scan/history/scan-overview"
/**
* Scan overview page
* Displays scan statistics and summary information
*/
export default function ScanOverviewPage() {
const { id } = useParams<{ id: string }>()
const scanId = Number(id)
return (
<div className="flex-1 flex flex-col min-h-0 px-4 lg:px-6">
<ScanOverview scanId={scanId} />
</div>
)
}

View File

@@ -8,7 +8,7 @@ export default function ScanHistoryDetailPage() {
const router = useRouter()
useEffect(() => {
router.replace(`/scan/history/${id}/websites/`)
router.replace(`/scan/history/${id}/overview/`)
}, [id, router])
return null

View File

@@ -0,0 +1,15 @@
"use client"
import { useParams } from "next/navigation"
import { ScreenshotsGallery } from "@/components/screenshots/screenshots-gallery"
export default function ScanScreenshotsPage() {
const { id } = useParams<{ id: string }>()
const scanId = Number(id)
return (
<div className="px-4 lg:px-6">
<ScreenshotsGallery scanId={scanId} />
</div>
)
}

View File

@@ -1,17 +1,10 @@
"use client"
import { useTranslations } from "next-intl"
import { SystemLogsView } from "@/components/settings/system-logs"
export default function SystemLogsPage() {
const t = useTranslations("settings.systemLogs")
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<div className="flex flex-1 flex-col p-4 h-full">
<SystemLogsView />
</div>
)

View File

@@ -3,7 +3,7 @@
import React from "react"
import { usePathname, useParams } from "next/navigation"
import Link from "next/link"
import { Target } from "lucide-react"
import { Target, LayoutDashboard, Package, Image, ShieldAlert, Settings } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
@@ -34,6 +34,7 @@ export default function TargetLayout({
// Get primary navigation active tab
const getPrimaryTab = () => {
if (pathname.includes("/overview")) return "overview"
if (pathname.includes("/screenshots")) return "screenshots"
if (pathname.includes("/vulnerabilities")) return "vulnerabilities"
if (pathname.includes("/settings")) return "settings"
// All asset pages fall under "assets"
@@ -67,6 +68,7 @@ export default function TargetLayout({
const primaryPaths = {
overview: `${basePath}/overview/`,
assets: `${basePath}/websites/`, // Default to websites when clicking assets
screenshots: `${basePath}/screenshots/`,
vulnerabilities: `${basePath}/vulnerabilities/`,
settings: `${basePath}/settings/`,
}
@@ -87,6 +89,7 @@ export default function TargetLayout({
directories: (target as any)?.summary?.directories || 0,
vulnerabilities: (target as any)?.summary?.vulnerabilities?.total || 0,
"ip-addresses": (target as any)?.summary?.ips || 0,
screenshots: (target as any)?.summary?.screenshots || 0,
}
// Calculate total assets count
@@ -162,12 +165,14 @@ export default function TargetLayout({
<Tabs value={getPrimaryTab()}>
<TabsList>
<TabsTrigger value="overview" asChild>
<Link href={primaryPaths.overview} className="flex items-center gap-0.5">
<Link href={primaryPaths.overview} className="flex items-center gap-1.5">
<LayoutDashboard className="h-4 w-4" />
{t("tabs.overview")}
</Link>
</TabsTrigger>
<TabsTrigger value="assets" asChild>
<Link href={primaryPaths.assets} className="flex items-center gap-0.5">
<Link href={primaryPaths.assets} className="flex items-center gap-1.5">
<Package className="h-4 w-4" />
{t("tabs.assets")}
{totalAssets > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
@@ -176,8 +181,20 @@ export default function TargetLayout({
)}
</Link>
</TabsTrigger>
<TabsTrigger value="screenshots" asChild>
<Link href={primaryPaths.screenshots} className="flex items-center gap-1.5">
<Image className="h-4 w-4" />
{t("tabs.screenshots")}
{counts.screenshots > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.screenshots}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="vulnerabilities" asChild>
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-0.5">
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-1.5">
<ShieldAlert className="h-4 w-4" />
{t("tabs.vulnerabilities")}
{counts.vulnerabilities > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
@@ -187,7 +204,8 @@ export default function TargetLayout({
</Link>
</TabsTrigger>
<TabsTrigger value="settings" asChild>
<Link href={primaryPaths.settings} className="flex items-center gap-0.5">
<Link href={primaryPaths.settings} className="flex items-center gap-1.5">
<Settings className="h-4 w-4" />
{t("tabs.settings")}
</Link>
</TabsTrigger>

View File

@@ -0,0 +1,15 @@
"use client"
import { useParams } from "next/navigation"
import { ScreenshotsGallery } from "@/components/screenshots/screenshots-gallery"
export default function ScreenshotsPage() {
const { id } = useParams<{ id: string }>()
const targetId = Number(id)
return (
<div className="px-4 lg:px-6">
<ScreenshotsGallery targetId={targetId} />
</div>
)
}

View File

@@ -114,7 +114,7 @@ export function DirectoriesDataTable({
onSelectionChange={handleSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
bulkDeleteLabel={tActions("delete")}
showAddButton={false}
// Bulk add button
onBulkAdd={onBulkAdd}

View File

@@ -11,6 +11,7 @@ import { useTargetDirectories, useScanDirectories } from "@/hooks/use-directorie
import { useTarget } from "@/hooks/use-targets"
import { DirectoryService } from "@/services/directory.service"
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { TargetType } from "@/lib/url-validator"
import type { Directory } from "@/types/directory.types"
@@ -29,6 +30,8 @@ export function DirectoriesView({
})
const [selectedDirectories, setSelectedDirectories] = useState<Directory[]>([])
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [filterQuery, setFilterQuery] = useState("")
const [isSearching, setIsSearching] = useState(false)
@@ -240,6 +243,26 @@ export function DirectoriesView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedDirectories.length === 0) return
setIsDeleting(true)
try {
const ids = selectedDirectories.map(d => d.id)
const result = await DirectoryService.bulkDelete(ids)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedDirectories([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete directories", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
@@ -280,6 +303,7 @@ export function DirectoriesView({
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
/>
@@ -295,6 +319,17 @@ export function DirectoriesView({
onSuccess={() => refetch()}
/>
)}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedDirectories.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)
}

View File

@@ -36,6 +36,7 @@ interface EndpointsDataTableProps<TData extends { id: number | string }, TValue>
onAddNew?: () => void
addButtonText?: string
onSelectionChange?: (selectedRows: TData[]) => void
onBulkDelete?: () => void
pagination?: { pageIndex: number; pageSize: number }
onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void
totalCount?: number
@@ -54,6 +55,7 @@ export function EndpointsDataTable<TData extends { id: number | string }, TValue
onAddNew,
addButtonText = "Add",
onSelectionChange,
onBulkDelete,
pagination: externalPagination,
onPaginationChange,
totalCount,
@@ -135,7 +137,8 @@ export function EndpointsDataTable<TData extends { id: number | string }, TValue
// Selection
onSelectionChange={onSelectionChange}
// Bulk operations
showBulkDelete={false}
onBulkDelete={onBulkDelete}
bulkDeleteLabel={tActions("delete")}
onAddNew={onAddNew}
addButtonLabel={addButtonText}
// Bulk add button

View File

@@ -10,6 +10,7 @@ import { createEndpointColumns } from "./endpoints-columns"
import { LoadingSpinner } from "@/components/loading-spinner"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { TargetType } from "@/lib/url-validator"
import {
@@ -41,6 +42,8 @@ export function EndpointsDetailView({
const [endpointToDelete, setEndpointToDelete] = useState<Endpoint | null>(null)
const [selectedEndpoints, setSelectedEndpoints] = useState<Endpoint[]>([])
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Pagination state management
const [pagination, setPagination] = useState({
@@ -280,6 +283,26 @@ export function EndpointsDetailView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedEndpoints.length === 0) return
setIsDeleting(true)
try {
const ids = selectedEndpoints.map(e => e.id)
const result = await EndpointService.bulkDelete(ids)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedEndpoints([])
setBulkDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete endpoints", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
// Error state
if (error) {
return (
@@ -327,6 +350,7 @@ export function EndpointsDetailView({
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setBulkDeleteDialogOpen(true) : undefined}
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
/>
@@ -343,7 +367,18 @@ export function EndpointsDetailView({
/>
)}
{/* Delete confirmation dialog */}
{/* Bulk delete confirmation dialog */}
<ConfirmDialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
title={tConfirm("deleteTitle")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedEndpoints.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
{/* Single delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>

View File

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

View File

@@ -54,6 +54,7 @@ export function IPAddressesDataTable({
}: IPAddressesDataTableProps) {
const t = useTranslations("common.status")
const tDownload = useTranslations("common.download")
const tActions = useTranslations("common.actions")
// Smart search handler
const handleSmartSearch = (rawQuery: string) => {
@@ -98,7 +99,7 @@ export function IPAddressesDataTable({
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
bulkDeleteLabel={tActions("delete")}
showAddButton={false}
// Download
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}

View File

@@ -8,6 +8,7 @@ import { createIPAddressColumns } from "./ip-addresses-columns"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import { Button } from "@/components/ui/button"
import { useTargetIPAddresses, useScanIPAddresses } from "@/hooks/use-ip-addresses"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { IPAddress } from "@/types/ip-address.types"
import { IPAddressService } from "@/services/ip-address.service"
@@ -26,6 +27,8 @@ export function IPAddressesView({
})
const [selectedIPAddresses, setSelectedIPAddresses] = useState<IPAddress[]>([])
const [filterQuery, setFilterQuery] = useState("")
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Internationalization
const tColumns = useTranslations("columns")
@@ -215,6 +218,27 @@ export function IPAddressesView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedIPAddresses.length === 0) return
setIsDeleting(true)
try {
// IP addresses are aggregated, pass IP strings instead of IDs
const ips = selectedIPAddresses.map(ip => ip.ip)
const result = await IPAddressService.bulkDelete(ips)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedIPAddresses([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete IP addresses", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
@@ -253,6 +277,18 @@ export function IPAddressesView({
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
/>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedIPAddresses.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)

View File

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

View File

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

View File

@@ -96,6 +96,7 @@ site_scan:
httpx:
enabled: true
timeout: auto # Auto calculate
# screenshot: true # Enable site screenshot (requires Chromium)
# ==================== Directory Scan ====================

View File

@@ -55,6 +55,7 @@ export function ScanHistoryDataTable({
}: ScanHistoryDataTableProps) {
const t = useTranslations("common.status")
const tScan = useTranslations("scan.history")
const tActions = useTranslations("common.actions")
// Search local state
const [localSearchValue, setLocalSearchValue] = React.useState(searchValue || "")
@@ -91,7 +92,7 @@ export function ScanHistoryDataTable({
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
bulkDeleteLabel={tActions("delete")}
onAddNew={onAddNew}
addButtonLabel={addButtonText || tScan("title")}
// Toolbar

View File

@@ -0,0 +1,475 @@
"use client"
import React, { useState } from "react"
import Link from "next/link"
import { useTranslations, useLocale } from "next-intl"
import {
Globe,
Network,
Server,
Link2,
FolderOpen,
AlertTriangle,
Clock,
Calendar,
ChevronRight,
Target,
CheckCircle2,
XCircle,
Loader2,
Cpu,
HardDrive,
} from "lucide-react"
import {
IconCircleCheck,
IconCircleX,
IconClock,
} from "@tabler/icons-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useScan } from "@/hooks/use-scans"
import { useScanLogs } from "@/hooks/use-scan-logs"
import { ScanLogList } from "@/components/scan/scan-log-list"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { getDateLocale } from "@/lib/date-utils"
import { cn } from "@/lib/utils"
import type { StageStatus } from "@/types/scan.types"
interface ScanOverviewProps {
scanId: number
}
/**
* Scan overview component
* Displays statistics cards for the scan results
*/
// Pulsing dot animation
function PulsingDot({ className }: { className?: string }) {
return (
<span className={cn("relative flex h-3 w-3", className)}>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-current" />
</span>
)
}
// Stage status icon
function StageStatusIcon({ status }: { status: StageStatus }) {
switch (status) {
case "completed":
return <IconCircleCheck className="h-5 w-5 text-[#238636] dark:text-[#3fb950]" />
case "running":
return <PulsingDot className="text-[#d29922]" />
case "failed":
return <IconCircleX className="h-5 w-5 text-[#da3633] dark:text-[#f85149]" />
case "cancelled":
return <IconCircleX className="h-5 w-5 text-[#848d97]" />
default:
return <IconClock className="h-5 w-5 text-muted-foreground" />
}
}
// Format duration (seconds -> readable string)
function formatStageDuration(seconds?: number): string | undefined {
if (seconds === undefined || seconds === null) return undefined
if (seconds < 1) return "<1s"
if (seconds < 60) return `${Math.round(seconds)}s`
const minutes = Math.floor(seconds / 60)
const secs = Math.round(seconds % 60)
return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m`
}
export function ScanOverview({ scanId }: ScanOverviewProps) {
const t = useTranslations("scan.history.overview")
const tStatus = useTranslations("scan.history.status")
const tProgress = useTranslations("scan.progress")
const locale = useLocale()
const { data: scan, isLoading, error } = useScan(scanId)
// Check if scan is running (for log polling)
const isRunning = scan?.status === 'running' || scan?.status === 'initiated'
// Auto-refresh state (default: on when running)
const [autoRefresh, setAutoRefresh] = useState(true)
// Tab state for logs/config
const [activeTab, setActiveTab] = useState<'logs' | 'config'>('logs')
// Logs hook
const { logs, loading: logsLoading } = useScanLogs({
scanId,
enabled: !!scan,
pollingInterval: isRunning && autoRefresh ? 3000 : 0,
})
// Format date helper
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return "-"
return new Date(dateString).toLocaleString(getDateLocale(locale), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
// Calculate duration
const formatDuration = (startedAt: string | undefined, completedAt: string | undefined): string => {
if (!startedAt) return "-"
const start = new Date(startedAt)
const end = completedAt ? new Date(completedAt) : new Date()
const diffMs = end.getTime() - start.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const remainingMins = diffMins % 60
if (diffHours > 0) {
return `${diffHours}h ${remainingMins}m`
}
return `${diffMins}m`
}
// Status style configuration (consistent with scan-history-columns)
const SCAN_STATUS_STYLES: Record<string, string> = {
running: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
cancelled: "bg-[#848d97]/10 text-[#848d97] border-[#848d97]/20",
completed: "bg-[#238636]/10 text-[#238636] border-[#238636]/20 dark:text-[#3fb950]",
failed: "bg-[#da3633]/10 text-[#da3633] border-[#da3633]/20 dark:text-[#f85149]",
initiated: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
pending: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
}
// Get status icon
const getStatusIcon = (status: string) => {
switch (status) {
case "completed":
return { icon: CheckCircle2, animate: false }
case "running":
return { icon: Loader2, animate: true }
case "failed":
return { icon: XCircle, animate: false }
case "cancelled":
return { icon: XCircle, animate: false }
case "pending":
case "initiated":
return { icon: Loader2, animate: true }
default:
return { icon: Clock, animate: false }
}
}
if (isLoading) {
return (
<div className="space-y-6">
{/* Stats cards skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
if (error || !scan) {
return (
<div className="flex flex-col items-center justify-center py-12">
<AlertTriangle className="h-10 w-10 text-destructive mb-4" />
<p className="text-muted-foreground">{t("loadError")}</p>
</div>
)
}
// Use type assertion for extended properties
const scanAny = scan as any
const summary = scanAny.summary || {}
const vulnSummary = summary.vulnerabilities || { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
const statusIconConfig = getStatusIcon(scan.status)
const StatusIcon = statusIconConfig.icon
const statusStyle = SCAN_STATUS_STYLES[scan.status] || "bg-muted text-muted-foreground"
const targetId = scanAny.target // Target ID
const targetName = scan.targetName // Target name
const startedAt = scanAny.startedAt || scan.createdAt
const completedAt = scanAny.completedAt
const assetCards = [
{
title: t("cards.websites"),
value: summary.websites || 0,
icon: Globe,
href: `/scan/history/${scanId}/websites/`,
},
{
title: t("cards.subdomains"),
value: summary.subdomains || 0,
icon: Network,
href: `/scan/history/${scanId}/subdomain/`,
},
{
title: t("cards.ips"),
value: summary.ips || 0,
icon: Server,
href: `/scan/history/${scanId}/ip-addresses/`,
},
{
title: t("cards.urls"),
value: summary.endpoints || 0,
icon: Link2,
href: `/scan/history/${scanId}/endpoints/`,
},
{
title: t("cards.directories"),
value: summary.directories || 0,
icon: FolderOpen,
href: `/scan/history/${scanId}/directories/`,
},
]
return (
<div className="flex flex-col gap-6 flex-1 min-h-0">
{/* Scan info + Status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-6 text-sm text-muted-foreground">
{/* Target */}
{targetId && targetName && (
<Link
href={`/target/${targetId}/overview/`}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
>
<Target className="h-4 w-4" />
<span>{targetName}</span>
</Link>
)}
{/* Started at */}
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>{t("startedAt")}: {formatDate(startedAt)}</span>
</div>
{/* Duration */}
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
<span>{t("duration")}: {formatDuration(startedAt, completedAt)}</span>
</div>
{/* Engine */}
{scan.engineNames && scan.engineNames.length > 0 && (
<div className="flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
<span>{scan.engineNames.join(", ")}</span>
</div>
)}
{/* Worker */}
{scan.workerName && (
<div className="flex items-center gap-1.5">
<HardDrive className="h-4 w-4" />
<span>{scan.workerName}</span>
</div>
)}
</div>
{/* Status badge */}
<Badge variant="outline" className={statusStyle}>
<StatusIcon className={`h-3.5 w-3.5 mr-1.5 ${statusIconConfig.animate ? 'animate-spin' : ''}`} />
{tStatus(scan.status)}
</Badge>
</div>
{/* Asset statistics cards */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("assetsTitle")}</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{assetCards.map((card) => (
<Link key={card.title} href={card.href}>
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<card.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value.toLocaleString()}</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
{/* Stage Progress + Logs - Left-Right Split Layout */}
<div className="grid gap-4 md:grid-cols-[280px_1fr] flex-1 min-h-0">
{/* Left Column: Stage Progress + Vulnerability Stats */}
<div className="flex flex-col gap-4 min-h-0">
{/* Stage Progress */}
<Card className="flex-1 min-h-0">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium">{t("stagesTitle")}</CardTitle>
{scan.stageProgress && (
<span className="text-xs text-muted-foreground">
{Object.values(scan.stageProgress).filter((p: any) => p.status === "completed").length}/
{Object.keys(scan.stageProgress).length} {t("stagesCompleted")}
</span>
)}
</CardHeader>
<CardContent className="pt-0 flex flex-col flex-1 min-h-0">
{scan.stageProgress && Object.keys(scan.stageProgress).length > 0 ? (
<div className="space-y-1 flex-1 min-h-0 overflow-y-auto pr-1">
{Object.entries(scan.stageProgress)
.sort(([, a], [, b]) => ((a as any).order ?? 0) - ((b as any).order ?? 0))
.map(([stageName, progress]) => {
const stageProgress = progress as any
const isRunning = stageProgress.status === "running"
return (
<div
key={stageName}
className={cn(
"flex items-center justify-between py-2 px-2 rounded-md transition-colors text-sm",
isRunning && "bg-[#d29922]/10 border border-[#d29922]/30",
stageProgress.status === "completed" && "text-muted-foreground",
stageProgress.status === "failed" && "bg-[#da3633]/10 text-[#da3633]",
stageProgress.status === "cancelled" && "text-muted-foreground",
)}
>
<div className="flex items-center gap-2 min-w-0">
<StageStatusIcon status={stageProgress.status} />
<span className={cn("truncate", isRunning && "font-medium text-foreground")}>
{tProgress(`stages.${stageName}`)}
</span>
{isRunning && (
<span className="text-[10px] text-[#d29922] shrink-0"></span>
)}
</div>
<span className="text-xs text-muted-foreground font-mono shrink-0 ml-2">
{stageProgress.status === "completed" && stageProgress.duration
? formatStageDuration(stageProgress.duration)
: stageProgress.status === "running"
? tProgress("stage_running")
: stageProgress.status === "pending"
? "--"
: ""}
</span>
</div>
)
})}
</div>
) : (
<div className="text-sm text-muted-foreground text-center py-4">
{t("noStages")}
</div>
)}
</CardContent>
</Card>
{/* Vulnerability Stats - Compact */}
<Link href={`/scan/history/${scanId}/vulnerabilities/`} className="block">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("vulnerabilitiesTitle")}</CardTitle>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-sm font-medium">{vulnSummary.critical}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-orange-500" />
<span className="text-sm font-medium">{vulnSummary.high}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500" />
<span className="text-sm font-medium">{vulnSummary.medium}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-sm font-medium">{vulnSummary.low}</span>
</div>
<span className="text-xs text-muted-foreground ml-auto">
{t("totalVulns", { count: vulnSummary.total })}
</span>
</div>
</CardContent>
</Card>
</Link>
</div>
{/* Right Column: Logs / Config */}
<div className="flex flex-col min-h-0 rounded-lg overflow-hidden border">
{/* Tab Header */}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30 border-b shrink-0">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'logs' | 'config')}>
<TabsList variant="underline" className="h-8 gap-3">
<TabsTrigger variant="underline" value="logs" className="text-xs">{t("logsTitle")}</TabsTrigger>
<TabsTrigger variant="underline" value="config" className="text-xs">{t("configTitle")}</TabsTrigger>
</TabsList>
</Tabs>
{/* Auto-refresh toggle (only for logs tab when running) */}
{activeTab === 'logs' && isRunning && (
<div className="flex items-center gap-2">
<Switch
id="log-auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
className="scale-75"
/>
<Label htmlFor="log-auto-refresh" className="text-xs cursor-pointer">
{t("autoRefresh")}
</Label>
</div>
)}
</div>
{/* Tab Content */}
<div className="flex-1 min-h-0">
{activeTab === 'logs' ? (
<ScanLogList logs={logs} loading={logsLoading} />
) : (
<div className="h-full">
{scan.yamlConfiguration ? (
<YamlEditor
value={scan.yamlConfiguration}
onChange={() => {}}
disabled={true}
height="100%"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{t("noConfig")}
</div>
)}
</div>
)}
</div>
{/* Bottom status bar (only for logs tab) */}
{activeTab === 'logs' && (
<div className="flex items-center px-4 py-2 bg-muted/50 border-t text-xs text-muted-foreground shrink-0">
<span>{logs.length} </span>
{isRunning && autoRefresh && (
<>
<Separator orientation="vertical" className="h-3 mx-3" />
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
3
</span>
</>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useEffect, useRef, useMemo } from "react"
import { useMemo, useRef } from "react"
import { AnsiLogViewer } from "@/components/settings/system-logs"
import type { ScanLog } from "@/services/scan.service"
interface ScanLogListProps {
@@ -14,98 +15,68 @@ interface ScanLogListProps {
function formatTime(isoString: string): string {
try {
const date = new Date(isoString)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${h}:${m}:${s}`
} catch {
return isoString
}
}
/**
* HTML 转义,防止 XSS
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 扫描日志列表组件
*
* 特性:
* - 预渲染 HTML 字符串,减少 DOM 节点提升性能
* - 颜色区分info=默认, warning=黄色, error=红色
* - 自动滚动到底部
* 复用 AnsiLogViewer 组件
*/
export function ScanLogList({ logs, loading }: ScanLogListProps) {
const containerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true) // 跟踪用户是否在底部
// 稳定的 content 引用,只有内容真正变化时才更新
const contentRef = useRef('')
const lastLogCountRef = useRef(0)
const lastLogIdRef = useRef<number | null>(null)
// 预渲染 HTML 字符串
const htmlContent = useMemo(() => {
// 将日志转换为纯文本格式
const content = useMemo(() => {
if (logs.length === 0) return ''
return logs.map(log => {
// 检查是否真正需要更新
const lastLog = logs[logs.length - 1]
if (
logs.length === lastLogCountRef.current &&
lastLog?.id === lastLogIdRef.current
) {
// 日志没有变化,返回缓存的 content
return contentRef.current
}
// 更新缓存
lastLogCountRef.current = logs.length
lastLogIdRef.current = lastLog?.id ?? null
const newContent = logs.map(log => {
const time = formatTime(log.createdAt)
const content = escapeHtml(log.content)
const levelStyle = log.level === 'error'
? 'color:#ef4444'
: log.level === 'warning'
? 'color:#eab308'
: ''
return `<div style="line-height:1.625;word-break:break-all;${levelStyle}"><span style="color:#6b7280">${time}</span> ${content}</div>`
}).join('')
const levelTag = log.level.toUpperCase()
return `[${time}] [${levelTag}] ${log.content}`
}).join('\n')
contentRef.current = newContent
return newContent
}, [logs])
// 监听滚动事件,检测用户是否在底部
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
// 允许 30px 的容差,认为在底部附近
isAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 30
}
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [])
if (loading && logs.length === 0) {
return (
<div className="h-full flex items-center justify-center bg-[#1e1e1e] text-[#808080]">
...
</div>
)
}
// 只有用户在底部时才自动滚动
useEffect(() => {
if (containerRef.current && isAtBottomRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [htmlContent])
if (logs.length === 0) {
return (
<div className="h-full flex items-center justify-center bg-[#1e1e1e] text-[#808080]">
</div>
)
}
return (
<div
ref={containerRef}
className="h-[400px] overflow-y-auto font-mono text-[11px] p-3 bg-muted/30 rounded-lg"
>
{logs.length === 0 && !loading && (
<div className="text-muted-foreground text-center py-8">
</div>
)}
{htmlContent && (
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
)}
{loading && logs.length === 0 && (
<div className="text-muted-foreground text-center py-8">
...
</div>
)}
</div>
)
return <AnsiLogViewer content={content} />
}

View File

@@ -0,0 +1,437 @@
"use client"
import React, { useState, useCallback, useMemo } from "react"
import { AlertTriangle, Image as ImageIcon, ExternalLink, Trash2, X, ChevronLeft, ChevronRight, Search } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Skeleton } from "@/components/ui/skeleton"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
import { useTargetScreenshots, useScanScreenshots } from "@/hooks/use-screenshots"
import { ScreenshotService } from "@/services/screenshot.service"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
const PAGE_SIZE_OPTIONS = [12, 24, 48]
interface Screenshot {
id: number
url: string
statusCode: number | null
createdAt: string
}
interface ScreenshotsGalleryProps {
targetId?: number
scanId?: number
}
export function ScreenshotsGallery({ targetId, scanId }: ScreenshotsGalleryProps) {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 12 })
const [searchInput, setSearchInput] = useState("") // 输入框的值
const [filterQuery, setFilterQuery] = useState("") // 实际用于查询的值
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
const t = useTranslations("pages.screenshots")
const tCommon = useTranslations("common")
const tToast = useTranslations("toast")
// Fetch screenshots
const targetQuery = useTargetScreenshots(
targetId || 0,
{ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, filter: filterQuery || undefined },
{ enabled: !!targetId }
)
const scanQuery = useScanScreenshots(
scanId || 0,
{ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, filter: filterQuery || undefined },
{ enabled: !!scanId }
)
const activeQuery = targetId ? targetQuery : scanQuery
const { data, isLoading, error, refetch } = activeQuery
const screenshots: Screenshot[] = useMemo(() => data?.results || [], [data])
const totalPages = data?.totalPages || 0
// Selection handlers
const toggleSelect = useCallback((id: number) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const selectAll = useCallback(() => {
if (selectedIds.size === screenshots.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(screenshots.map(s => s.id)))
}
}, [screenshots, selectedIds.size])
// Delete handler
const handleBulkDelete = async () => {
if (selectedIds.size === 0) return
setIsDeleting(true)
try {
const result = await ScreenshotService.bulkDelete(Array.from(selectedIds))
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedIds(new Set())
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete screenshots", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
// Filter handler - 手动触发搜索
const handleSearch = () => {
setFilterQuery(searchInput)
setPagination(prev => ({ ...prev, pageIndex: 0 }))
}
// 回车触发搜索
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch()
}
}
// Handle page size change
const handlePageSizeChange = (value: string) => {
const newPageSize = parseInt(value, 10)
setPagination({ pageIndex: 0, pageSize: newPageSize })
}
// Lightbox handlers
const openLightbox = (index: number) => {
setLightboxIndex(index)
setLightboxOpen(true)
}
const nextImage = () => {
setLightboxIndex(prev => (prev + 1) % screenshots.length)
}
const prevImage = () => {
setLightboxIndex(prev => (prev - 1 + screenshots.length) % screenshots.length)
}
// Get image URL
const getImageUrl = (screenshot: Screenshot) => {
if (scanId) {
return ScreenshotService.getSnapshotImageUrl(scanId, screenshot.id)
}
return ScreenshotService.getImageUrl(screenshot.id)
}
// Error state
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="rounded-full bg-destructive/10 p-3 mb-4">
<AlertTriangle className="h-10 w-10 text-destructive" />
</div>
<h3 className="text-lg font-semibold mb-2">{tCommon("status.error")}</h3>
<p className="text-muted-foreground text-center mb-4">
{t("loadError")}
</p>
<Button onClick={() => refetch()}>{tCommon("actions.retry")}</Button>
</div>
)
}
// Loading state
if (isLoading && !data) {
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-64" />
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-video rounded-lg" />
))}
</div>
</div>
)
}
// Empty state
if (screenshots.length === 0 && !filterQuery) {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="rounded-full bg-muted p-3 mb-4">
<ImageIcon className="h-10 w-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">{t("empty.title")}</h3>
<p className="text-muted-foreground text-center">
{t("empty.description")}
</p>
</div>
)
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Input
placeholder={t("filterPlaceholder")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleKeyDown}
className="w-64"
/>
<Button variant="outline" size="sm" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
{targetId && selectedIds.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4 mr-1" />
{tCommon("actions.delete")} ({selectedIds.size})
</Button>
)}
{screenshots.length > 0 && targetId && (
<Button variant="outline" size="sm" onClick={selectAll}>
{selectedIds.size === screenshots.length ? tCommon("actions.deselectAll") : tCommon("actions.selectAll")}
</Button>
)}
</div>
</div>
{/* Gallery grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{screenshots.map((screenshot, index) => (
<div
key={screenshot.id}
className={cn(
"group relative aspect-video rounded-lg overflow-hidden border bg-muted cursor-pointer transition-all",
selectedIds.has(screenshot.id) && "ring-2 ring-primary"
)}
>
{/* Checkbox */}
{targetId && (
<div
className="absolute top-2 left-2 z-10"
onClick={(e) => {
e.stopPropagation()
toggleSelect(screenshot.id)
}}
>
<Checkbox
checked={selectedIds.has(screenshot.id)}
className="bg-background/80 backdrop-blur-sm"
/>
</div>
)}
{/* Image */}
<img
src={getImageUrl(screenshot)}
alt={screenshot.url}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
onClick={() => openLightbox(index)}
loading="lazy"
/>
{/* Overlay with URL and status code */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2">
<div className="flex items-center gap-2">
{screenshot.statusCode && (
<span className={cn(
"text-xs px-1.5 py-0.5 rounded font-medium shrink-0",
screenshot.statusCode >= 200 && screenshot.statusCode < 300 && "bg-green-500/80 text-white",
screenshot.statusCode >= 300 && screenshot.statusCode < 400 && "bg-blue-500/80 text-white",
screenshot.statusCode >= 400 && screenshot.statusCode < 500 && "bg-yellow-500/80 text-black",
screenshot.statusCode >= 500 && "bg-red-500/80 text-white"
)}>
{screenshot.statusCode}
</span>
)}
<p className="text-white text-xs truncate" title={screenshot.url}>
{screenshot.url}
</p>
</div>
</div>
{/* Hover actions */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<a
href={screenshot.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center h-8 w-8 rounded-md bg-background/80 backdrop-blur-sm hover:bg-background"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
))}
</div>
{/* Empty search results */}
{screenshots.length === 0 && filterQuery && (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">{t("noResults")}</p>
</div>
)}
{/* Pagination */}
{(totalPages > 1 || (data?.total ?? 0) > 12) && (
<div className="flex justify-center items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPagination(prev => ({ ...prev, pageIndex: Math.max(0, prev.pageIndex - 1) }))}
disabled={pagination.pageIndex === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
{pagination.pageIndex + 1} / {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPagination(prev => ({ ...prev, pageIndex: Math.min(totalPages - 1, prev.pageIndex + 1) }))}
disabled={pagination.pageIndex >= totalPages - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Select value={String(pagination.pageSize)} onValueChange={handlePageSizeChange}>
<SelectTrigger className="w-20 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map(size => (
<SelectItem key={size} value={String(size)}>{size}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Lightbox */}
<Dialog open={lightboxOpen} onOpenChange={setLightboxOpen}>
<DialogContent className="max-w-[90vw] max-h-[90vh] p-0 bg-black/95 border-none">
<VisuallyHidden>
<DialogTitle>{t("lightboxTitle")}</DialogTitle>
</VisuallyHidden>
<div className="relative w-full h-full flex items-center justify-center">
{/* Close button */}
<button
onClick={() => setLightboxOpen(false)}
className="absolute top-4 right-4 z-50 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<X className="h-6 w-6 text-white" />
</button>
{/* Navigation */}
{screenshots.length > 1 && (
<>
<button
onClick={prevImage}
className="absolute left-4 z-50 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<ChevronLeft className="h-8 w-8 text-white" />
</button>
<button
onClick={nextImage}
className="absolute right-4 z-50 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<ChevronRight className="h-8 w-8 text-white" />
</button>
</>
)}
{/* Image */}
{screenshots[lightboxIndex] && (
<div className="flex flex-col items-center gap-4 p-8">
<img
src={getImageUrl(screenshots[lightboxIndex])}
alt={screenshots[lightboxIndex].url}
className="max-w-full max-h-[70vh] object-contain"
/>
<div className="text-white text-center">
<p className="text-sm opacity-80">{lightboxIndex + 1} / {screenshots.length}</p>
<div className="flex items-center gap-2 justify-center mt-1">
{screenshots[lightboxIndex].statusCode && (
<span className={cn(
"text-sm px-2 py-0.5 rounded font-medium",
screenshots[lightboxIndex].statusCode >= 200 && screenshots[lightboxIndex].statusCode < 300 && "bg-green-500 text-white",
screenshots[lightboxIndex].statusCode >= 300 && screenshots[lightboxIndex].statusCode < 400 && "bg-blue-500 text-white",
screenshots[lightboxIndex].statusCode >= 400 && screenshots[lightboxIndex].statusCode < 500 && "bg-yellow-500 text-black",
screenshots[lightboxIndex].statusCode >= 500 && "bg-red-500 text-white"
)}>
{screenshots[lightboxIndex].statusCode}
</span>
)}
<a
href={screenshots[lightboxIndex].url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline flex items-center gap-1"
>
{screenshots[lightboxIndex].url}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedIds.size })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</div>
)
}

View File

@@ -2,10 +2,13 @@
import { useMemo, useRef, useEffect } from "react"
import AnsiToHtml from "ansi-to-html"
import type { LogLevel } from "./log-toolbar"
interface AnsiLogViewerProps {
content: string
className?: string
searchQuery?: string
logLevel?: LogLevel
}
// 日志级别颜色配置
@@ -79,7 +82,102 @@ function colorizeLogContent(content: string): string {
.join("\n")
}
export function AnsiLogViewer({ content, className }: AnsiLogViewerProps) {
// 高亮搜索关键词
function highlightSearch(html: string, query: string): string {
if (!query.trim()) return html
// 转义正则特殊字符
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = new RegExp(`(${escapedQuery})`, "gi")
// 在标签外的文本中高亮关键词
return html.replace(/(<[^>]+>)|([^<]+)/g, (match, tag, text) => {
if (tag) return tag
if (text) {
return text.replace(regex, '<mark style="background:#fbbf24;color:#1e1e1e;border-radius:2px;padding:0 2px">$1</mark>')
}
return match
})
}
// 多种日志格式的级别提取正则
const LOG_LEVEL_PATTERNS = [
// 标准格式: [2026-01-07 12:00:00] [INFO]
/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] \[(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)\]/i,
// Prefect 格式: 12:01:50.419 | WARNING | prefect
/^[\d:.]+\s+\|\s+(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)\s+\|/i,
// 简单格式: [INFO] message 或 INFO: message
/^(?:\[)?(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)(?:\])?[:\s]/i,
// Python logging 格式: INFO - message
/^(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)\s+-\s+/i,
]
// 新日志条目起始模式(无级别但表示新条目开始)
const NEW_ENTRY_PATTERNS = [
/^\[\d+\/\d+\]/, // [1/4], [2/4] 等步骤标记
/^\[CONFIG\]/i, // [CONFIG] 配置信息
/^\[诊断\]/, // [诊断] 诊断信息
/^={10,}$/, // ============ 分隔线
/^\[\d{4}-\d{2}-\d{2}/, // 时间戳开头 [2026-01-07...
/^\d{2}:\d{2}:\d{2}/, // 时间开头 12:01:50...
/^\/[\w/]+\.py:\d+:/, // Python 文件路径 /path/file.py:123:
]
// 从行中提取日志级别
function extractLogLevel(line: string): string | null {
for (const pattern of LOG_LEVEL_PATTERNS) {
const match = line.match(pattern)
if (match) {
return match[1].toUpperCase()
}
}
return null
}
// 检测是否是新日志条目的起始(无级别)
function isNewEntryStart(line: string): boolean {
return NEW_ENTRY_PATTERNS.some((pattern) => pattern.test(line))
}
// 级别标准化
function normalizeLevel(l: string): string {
const upper = l.toUpperCase()
if (upper === "WARNING") return "WARN"
if (upper === "CRITICAL") return "ERROR"
return upper
}
// 根据级别筛选日志行
// 支持多行日志:非标准格式的行会跟随前一个标准日志行的级别
function filterByLevel(content: string, level: LogLevel): string {
if (level === "all") return content
const targetLevel = normalizeLevel(level)
const lines = content.split("\n")
const result: string[] = []
// 默认隐藏,直到遇到第一个匹配目标级别的日志行
let currentBlockVisible = false
for (const line of lines) {
const extractedLevel = extractLogLevel(line)
if (extractedLevel) {
// 这是一个新的日志条目,精确匹配级别
const lineLevel = normalizeLevel(extractedLevel)
currentBlockVisible = lineLevel === targetLevel
} else if (isNewEntryStart(line)) {
// 无级别但是新条目开始,隐藏
currentBlockVisible = false
}
// 非标准行跟随前一个日志条目的可见性
if (currentBlockVisible) {
result.push(line)
}
}
return result.join("\n")
}
export function AnsiLogViewer({ content, className, searchQuery = "", logLevel = "all" }: AnsiLogViewerProps) {
const containerRef = useRef<HTMLPreElement>(null)
const isAtBottomRef = useRef(true) // 跟踪用户是否在底部
@@ -88,14 +186,21 @@ export function AnsiLogViewer({ content, className }: AnsiLogViewerProps) {
const htmlContent = useMemo(() => {
if (!content) return ""
// 先按级别筛选
const filteredContent = filterByLevel(content, logLevel)
let result: string
// 如果包含 ANSI 颜色码,直接转换
if (hasAnsiCodes(content)) {
return ansiConverter.toHtml(content)
if (hasAnsiCodes(filteredContent)) {
result = ansiConverter.toHtml(filteredContent)
} else {
// 否则解析日志级别添加颜色
result = colorizeLogContent(filteredContent)
}
// 否则解析日志级别添加颜色
return colorizeLogContent(content)
}, [content])
// 应用搜索高亮
return highlightSearch(result, searchQuery)
}, [content, searchQuery, logLevel])
// 监听滚动事件,检测用户是否在底部
useEffect(() => {

View File

@@ -2,6 +2,7 @@
import { useMemo } from "react"
import { useTranslations } from "next-intl"
import { FileText, Search } from "lucide-react"
import {
Select,
@@ -12,30 +13,36 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import type { LogFile } from "@/types/system-log.types"
const LINE_OPTIONS = [100, 200, 500, 1000, 5000] as const
const LINE_OPTIONS = [100, 200, 500, 1000, 5000, 10000] as const
export type LogLevel = "all" | "DEBUG" | "INFO" | "WARN" | "ERROR"
export const LOG_LEVELS: LogLevel[] = ["all", "DEBUG", "INFO", "WARN", "ERROR"]
interface LogToolbarProps {
files: LogFile[]
selectedFile: string
lines: number
autoRefresh: boolean
searchQuery: string
logLevel: LogLevel
onFileChange: (filename: string) => void
onLinesChange: (lines: number) => void
onAutoRefreshChange: (enabled: boolean) => void
onSearchChange: (query: string) => void
onLogLevelChange: (level: LogLevel) => void
}
export function LogToolbar({
files,
selectedFile,
lines,
autoRefresh,
searchQuery,
logLevel,
onFileChange,
onLinesChange,
onAutoRefreshChange,
onSearchChange,
onLogLevelChange,
}: LogToolbarProps) {
const t = useTranslations("settings.systemLogs")
@@ -49,76 +56,75 @@ export function LogToolbar({
}, [files])
return (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
{/* 日志文件选择 */}
<div className="flex items-center gap-2">
<Label className="text-sm text-muted-foreground whitespace-nowrap">
{t("toolbar.logFile")}
</Label>
<Select value={selectedFile} onValueChange={onFileChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={t("toolbar.selectFile")} />
</SelectTrigger>
<SelectContent>
{groupedFiles.systemLogs.length > 0 && (
<SelectGroup>
<SelectLabel>{t("toolbar.systemLogsGroup")}</SelectLabel>
{groupedFiles.systemLogs.map((file) => (
<SelectItem key={file.filename} value={file.filename}>
{file.filename}
</SelectItem>
))}
</SelectGroup>
)}
{groupedFiles.containerLogs.length > 0 && (
<SelectGroup>
<SelectLabel>{t("toolbar.containerLogsGroup")}</SelectLabel>
{groupedFiles.containerLogs.map((file) => (
<SelectItem key={file.filename} value={file.filename}>
{file.filename}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
<Select value={selectedFile} onValueChange={onFileChange}>
<SelectTrigger className="w-[200px]">
<FileText className="h-4 w-4 text-muted-foreground" />
<SelectValue placeholder={t("toolbar.selectFile")} />
</SelectTrigger>
<SelectContent>
{groupedFiles.systemLogs.length > 0 && (
<SelectGroup>
<SelectLabel>{t("toolbar.systemLogsGroup")}</SelectLabel>
{groupedFiles.systemLogs.map((file) => (
<SelectItem key={file.filename} value={file.filename}>
{file.filename}
</SelectItem>
))}
</SelectGroup>
)}
{groupedFiles.containerLogs.length > 0 && (
<SelectGroup>
<SelectLabel>{t("toolbar.containerLogsGroup")}</SelectLabel>
{groupedFiles.containerLogs.map((file) => (
<SelectItem key={file.filename} value={file.filename}>
{file.filename}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
{/* 行数选择 */}
<div className="flex items-center gap-2">
<Label className="text-sm text-muted-foreground whitespace-nowrap">
{t("toolbar.lines")}
</Label>
<Select value={String(lines)} onValueChange={(v) => onLinesChange(Number(v))}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={String(lines)} onValueChange={(v) => onLinesChange(Number(v))}>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option} {t("toolbar.linesUnit")}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 自动刷新开关 */}
<div className="flex items-center gap-2">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={onAutoRefreshChange}
{/* 日志级别筛选 */}
<Select value={logLevel} onValueChange={(v) => onLogLevelChange(v as LogLevel)}>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOG_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{level === "all" ? t("toolbar.levelAll") : level}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 搜索框 - 居中并扩展 */}
<div className="relative flex-1 min-w-[280px] max-w-[500px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder={t("toolbar.searchPlaceholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full pl-9 h-9"
/>
<Label
htmlFor="auto-refresh"
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
>
{t("toolbar.autoRefresh")}
{autoRefresh && (
<span className="size-2 rounded-full bg-green-500 animate-pulse" />
)}
</Label>
</div>
</div>
)

View File

@@ -1,11 +1,15 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { Download } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { useSystemLogs, useLogFiles } from "@/hooks/use-system-logs"
import { LogToolbar } from "./log-toolbar"
import { LogToolbar, type LogLevel } from "./log-toolbar"
import { AnsiLogViewer } from "./ansi-log-viewer"
const DEFAULT_FILE = "xingrin.log"
@@ -18,6 +22,8 @@ export function SystemLogsView() {
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE)
const [lines, setLines] = useState(DEFAULT_LINES)
const [autoRefresh, setAutoRefresh] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const [logLevel, setLogLevel] = useState<LogLevel>("all")
// 获取日志文件列表
const { data: filesData } = useLogFiles()
@@ -45,28 +51,94 @@ export function SystemLogsView() {
return result
}, [logsData])
// 下载日志
const handleDownload = useCallback(() => {
if (!content) return
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = selectedFile
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [content, selectedFile])
return (
<Card>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 flex-1 min-h-0">
{/* 紧凑单行工具栏 - 标题融入 */}
<div className="flex items-center gap-4 flex-wrap">
<h1 className="text-lg font-semibold whitespace-nowrap">{t("title")}</h1>
<Separator orientation="vertical" className="h-5" />
<LogToolbar
files={files}
selectedFile={selectedFile}
lines={lines}
autoRefresh={autoRefresh}
searchQuery={searchQuery}
logLevel={logLevel}
onFileChange={setSelectedFile}
onLinesChange={setLines}
onAutoRefreshChange={setAutoRefresh}
onSearchChange={setSearchQuery}
onLogLevelChange={setLogLevel}
/>
<div className="h-[calc(100vh-300px)] min-h-[360px] rounded-lg border overflow-hidden bg-[#1e1e1e]">
{/* 下载按钮 */}
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleDownload}
disabled={!content}
>
<Download className="h-4 w-4 mr-1.5" />
{t("toolbar.download")}
</Button>
</div>
{/* 日志查看器 */}
<div className="flex-1 flex flex-col rounded-lg overflow-hidden border">
<div className="flex-1 min-h-[400px] bg-[#1e1e1e]">
{content ? (
<AnsiLogViewer content={content} />
<AnsiLogViewer content={content} searchQuery={searchQuery} logLevel={logLevel} />
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t("noContent")}
</div>
)}
</div>
</CardContent>
</Card>
{/* 底部状态栏 */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-t text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>{lines} {t("toolbar.linesUnit")}</span>
<Separator orientation="vertical" className="h-3" />
<span>{selectedFile}</span>
<Separator orientation="vertical" className="h-3" />
<span className="flex items-center gap-1.5">
{autoRefresh && (
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
)}
{t("description")}
</span>
</div>
{/* 自动刷新开关 */}
<div className="flex items-center gap-2">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
className="scale-75"
/>
<Label
htmlFor="auto-refresh"
className="text-xs cursor-pointer"
>
{t("toolbar.autoRefresh")}
</Label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -125,7 +125,7 @@ export function SubdomainsDataTable({
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
bulkDeleteLabel={tActions("delete")}
// Add button
onAddNew={onAddNew}
addButtonLabel={addButtonText}

View File

@@ -14,8 +14,10 @@ import { createSubdomainColumns } from "./subdomains-columns"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import { SubdomainService } from "@/services/subdomain.service"
import { BulkAddSubdomainsDialog } from "./bulk-add-subdomains-dialog"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { Subdomain } from "@/types/subdomain.types"
import { toast } from "sonner"
/**
* Subdomain detail view component
@@ -31,11 +33,14 @@ export function SubdomainsDetailView({
scanId?: number
}) {
const [selectedSubdomains, setSelectedSubdomains] = useState<Subdomain[]>([])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Internationalization
const tColumns = useTranslations("columns")
const tCommon = useTranslations("common")
const tSubdomains = useTranslations("subdomains")
const tToast = useTranslations("toast")
const locale = useLocale()
// Build translation object
@@ -215,6 +220,26 @@ export function SubdomainsDetailView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedSubdomains.length === 0) return
setIsDeleting(true)
try {
const ids = selectedSubdomains.map(s => s.id)
const result = await SubdomainService.bulkDeleteSubdomains(ids)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedSubdomains([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete subdomains", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
// Create column definitions
const subdomainColumns = useMemo(
() =>
@@ -279,6 +304,7 @@ export function SubdomainsDetailView({
isSearching={isSearching}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
pagination={pagination}
setPagination={setPagination}
paginationInfo={{
@@ -301,6 +327,17 @@ export function SubdomainsDetailView({
onSuccess={() => refetch()}
/>
)}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedSubdomains.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)
}

View File

@@ -298,7 +298,7 @@ export function TargetOverview({ targetId }: TargetOverviewProps) {
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer flex flex-col">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-red-500" />
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">{t("vulnerabilitiesTitle")}</CardTitle>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs">

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
onConfirm: () => void | Promise<void>
loading?: boolean
variant?: "default" | "destructive"
confirmText?: string
cancelText?: string
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
onConfirm,
loading = false,
variant = "default",
confirmText,
cancelText,
}: ConfirmDialogProps) {
const t = useTranslations("common.actions")
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{cancelText || t("cancel")}
</Button>
<Button
variant={variant}
onClick={onConfirm}
disabled={loading}
>
{loading ? t("processing") : (confirmText || t("confirm"))}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -58,6 +58,7 @@ export function VulnerabilitiesDataTable({
}: VulnerabilitiesDataTableProps) {
const t = useTranslations("common.status")
const tDownload = useTranslations("common.download")
const tActions = useTranslations("common.actions")
// Handle smart filter search
const handleFilterSearch = (rawQuery: string) => {
@@ -102,7 +103,7 @@ export function VulnerabilitiesDataTable({
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
bulkDeleteLabel={tActions("delete")}
showAddButton={false}
// Download
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}

View File

@@ -111,7 +111,7 @@ export function WebSitesDataTable({
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
bulkDeleteLabel={tActions("delete")}
showAddButton={false}
// Bulk add button
onBulkAdd={onBulkAdd}

View File

@@ -11,6 +11,7 @@ import { useTargetWebSites, useScanWebSites } from "@/hooks/use-websites"
import { useTarget } from "@/hooks/use-targets"
import { WebsiteService } from "@/services/website.service"
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { TargetType } from "@/lib/url-validator"
import type { WebSite } from "@/types/website.types"
@@ -29,6 +30,8 @@ export function WebSitesView({
})
const [selectedWebSites, setSelectedWebSites] = useState<WebSite[]>([])
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [filterQuery, setFilterQuery] = useState("")
const [isSearching, setIsSearching] = useState(false)
@@ -251,6 +254,26 @@ export function WebSitesView({
URL.revokeObjectURL(url)
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedWebSites.length === 0) return
setIsDeleting(true)
try {
const ids = selectedWebSites.map(w => w.id)
const result = await WebsiteService.bulkDelete(ids)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedWebSites([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete websites", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
@@ -291,6 +314,7 @@ export function WebSitesView({
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
/>
@@ -306,6 +330,17 @@ export function WebSitesView({
onSuccess={() => refetch()}
/>
)}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedWebSites.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)
}

View File

@@ -83,16 +83,18 @@ export function useScanLogs({
return () => {
isMounted.current = false
}
}, [scanId, enabled])
}, [scanId, enabled, fetchLogs])
// 轮询
useEffect(() => {
if (!enabled) return
// pollingInterval <= 0 表示禁用轮询(避免 setInterval(0) 导致高频请求/卡顿)
if (!pollingInterval || pollingInterval <= 0) return
const interval = setInterval(() => {
fetchLogs(true) // 增量查询
fetchLogs(true) // 增量查询
}, pollingInterval)
return () => clearInterval(interval)
}, [enabled, pollingInterval, fetchLogs])

View File

@@ -0,0 +1,30 @@
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { ScreenshotService, type PaginatedResponse, type Screenshot, type ScreenshotSnapshot } from '@/services/screenshot.service'
// 获取目标的截图列表
export function useTargetScreenshots(
targetId: number,
params: { page: number; pageSize: number; filter?: string },
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ['target-screenshots', targetId, params],
queryFn: () => ScreenshotService.getByTarget(targetId, params),
enabled: options?.enabled ?? true,
placeholderData: keepPreviousData,
})
}
// 获取扫描的截图快照列表
export function useScanScreenshots(
scanId: number,
params: { page: number; pageSize: number; filter?: string },
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ['scan-screenshots', scanId, params],
queryFn: () => ScreenshotService.getByScan(scanId, params),
enabled: options?.enabled ?? true,
placeholderData: keepPreviousData,
})
}

View File

@@ -64,14 +64,20 @@ export function getEngineIcon(capabilities: string[]): LucideIcon {
/**
* Parse engine configuration to get capability list
* Only matches top-level YAML keys (not comments or nested content)
*/
export function parseEngineCapabilities(configuration: string): string[] {
if (!configuration) return []
try {
const capabilities: string[] = []
const lines = configuration.split('\n')
Object.keys(CAPABILITY_CONFIG).forEach((key) => {
if (configuration.includes(key)) {
// Match top-level YAML key: starts with the key name followed by colon
// Must be at the beginning of a line (no leading whitespace)
const pattern = new RegExp(`^${key}\\s*:`, 'm')
if (pattern.test(configuration)) {
capabilities.push(key)
}
})

View File

@@ -168,12 +168,16 @@
"pause": "Pause",
"resume": "Resume",
"retry": "Retry",
"snapshot": "Snapshot",
"snapshot": "Details",
"openMenu": "Open menu",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"selectRow": "Select row",
"website": "Website",
"description": "Description"
"description": "Description",
"processing": "Processing...",
"confirmDelete": "Confirm Delete",
"deleteConfirmMessage": "Are you sure you want to delete {count} selected items? This action cannot be undone."
},
"yamlEditor": {
"syntaxError": "Syntax Error",
@@ -719,6 +723,54 @@
"loadFailed": "Failed to load scan history",
"retry": "Retry",
"taskId": "Scan Task ID: {id}",
"breadcrumb": {
"scanHistory": "Scan History"
},
"tabs": {
"overview": "Overview",
"assets": "Assets",
"screenshots": "Screenshots",
"vulnerabilities": "Vulnerabilities"
},
"status": {
"completed": "Completed",
"running": "Running",
"failed": "Failed",
"pending": "Pending",
"cancelled": "Cancelled",
"initiated": "Waiting"
},
"overview": {
"loadError": "Failed to load scan data",
"startedAt": "Started",
"duration": "Duration",
"assetsTitle": "Discovered Assets",
"vulnerabilitiesTitle": "Vulnerabilities",
"stagesTitle": "Scan Progress",
"stagesCompleted": "completed",
"logsTitle": "Scan Logs",
"configTitle": "Configuration",
"autoRefresh": "Auto Refresh",
"noConfig": "No configuration available",
"noStages": "No stage progress available",
"totalFound": "total found",
"totalVulns": "{count} total",
"viewAll": "View All",
"cards": {
"websites": "Websites",
"subdomains": "Subdomains",
"ips": "IP Addresses",
"urls": "URLs",
"directories": "Directories",
"vulnerabilities": "Vulnerabilities"
},
"severity": {
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
}
},
"stats": {
"totalScans": "Total Scans",
"running": "Running",
@@ -764,7 +816,8 @@
"fingerprint_detect": "Fingerprint Detection",
"directory_scan": "Directory Scan",
"url_fetch": "URL Fetch",
"vuln_scan": "Vulnerability Scan"
"vuln_scan": "Vulnerability Scan",
"screenshot": "Screenshot"
}
},
"scheduled": {
@@ -893,6 +946,7 @@
"site_scan": "Site Scan",
"fingerprint_detect": "Fingerprint Detection",
"directory_scan": "Directory Scan",
"screenshot": "Screenshot",
"url_fetch": "URL Fetch",
"vuln_scan": "Vulnerability Scan"
},
@@ -1402,6 +1456,10 @@
"logFile": "Log",
"selectFile": "Select log file",
"lines": "Lines",
"linesUnit": "lines",
"searchPlaceholder": "Search logs...",
"download": "Download",
"levelAll": "All Levels",
"autoRefresh": "Auto Refresh",
"systemLogsGroup": "System Logs",
"containerLogsGroup": "Container Logs"
@@ -1456,8 +1514,9 @@
"copied": "Copied",
"copyFailed": "Copy failed",
"downloadFailed": "Download failed",
"deletedScanRecord": "Deleted scan record: {name}",
"deleteSuccess": "Deleted successfully: {count} items",
"deleteFailed": "Delete failed, please retry",
"deletedScanRecord": "Deleted scan record: {name}",
"stoppedScan": "Stopped scan task: {name}",
"stopFailed": "Stop failed, please retry",
"bulkDeleteSuccess": "Deleted {count} scan records",
@@ -2019,6 +2078,16 @@
"description": "Common tool operations"
}
},
"screenshots": {
"filterPlaceholder": "Filter by URL...",
"loadError": "Failed to load screenshots",
"noResults": "No screenshots match your filter",
"lightboxTitle": "Screenshot Preview",
"empty": {
"title": "No Screenshots",
"description": "Screenshots will appear here after running a site scan with screenshot enabled."
}
},
"targetDetail": {
"noDescription": "No description",
"breadcrumb": {
@@ -2035,6 +2104,7 @@
"tabs": {
"overview": "Overview",
"assets": "Assets",
"screenshots": "Screenshots",
"vulnerabilities": "Vulnerabilities",
"settings": "Settings"
},

View File

@@ -168,12 +168,16 @@
"pause": "暂停",
"resume": "继续",
"retry": "重试",
"snapshot": "快照",
"snapshot": "详情",
"openMenu": "打开菜单",
"selectAll": "全选",
"deselectAll": "取消全选",
"selectRow": "选择行",
"website": "官网",
"description": "描述"
"description": "描述",
"processing": "处理中...",
"confirmDelete": "确认删除",
"deleteConfirmMessage": "确定要删除选中的 {count} 条记录吗?此操作不可撤销。"
},
"yamlEditor": {
"syntaxError": "语法错误",
@@ -719,6 +723,54 @@
"loadFailed": "加载扫描历史失败",
"retry": "重试",
"taskId": "扫描任务 ID{id}",
"breadcrumb": {
"scanHistory": "扫描历史"
},
"tabs": {
"overview": "概览",
"assets": "资产",
"screenshots": "截图",
"vulnerabilities": "漏洞"
},
"status": {
"completed": "已完成",
"running": "扫描中",
"failed": "失败",
"pending": "等待中",
"cancelled": "已取消",
"initiated": "等待中"
},
"overview": {
"loadError": "加载扫描数据失败",
"startedAt": "开始时间",
"duration": "耗时",
"assetsTitle": "发现的资产",
"vulnerabilitiesTitle": "漏洞统计",
"stagesTitle": "扫描进度",
"stagesCompleted": "完成",
"logsTitle": "扫描日志",
"configTitle": "扫描配置",
"autoRefresh": "自动刷新",
"noConfig": "暂无配置信息",
"noStages": "暂无阶段进度",
"totalFound": "个漏洞",
"totalVulns": "共 {count} 个",
"viewAll": "查看全部",
"cards": {
"websites": "网站",
"subdomains": "子域名",
"ips": "IP 地址",
"urls": "URLs",
"directories": "目录",
"vulnerabilities": "漏洞"
},
"severity": {
"critical": "严重",
"high": "高危",
"medium": "中危",
"low": "低危"
}
},
"stats": {
"totalScans": "总扫描数",
"running": "进行中",
@@ -764,7 +816,8 @@
"fingerprint_detect": "指纹识别",
"directory_scan": "目录扫描",
"url_fetch": "URL 抓取",
"vuln_scan": "漏洞扫描"
"vuln_scan": "漏洞扫描",
"screenshot": "截图"
}
},
"scheduled": {
@@ -893,6 +946,7 @@
"site_scan": "站点扫描",
"fingerprint_detect": "指纹识别",
"directory_scan": "目录扫描",
"screenshot": "网站截图",
"url_fetch": "URL 抓取",
"vuln_scan": "漏洞扫描"
},
@@ -1402,6 +1456,10 @@
"logFile": "日志",
"selectFile": "选择日志文件",
"lines": "行数",
"linesUnit": "行",
"searchPlaceholder": "搜索日志...",
"download": "下载",
"levelAll": "全部级别",
"autoRefresh": "自动刷新",
"systemLogsGroup": "系统日志",
"containerLogsGroup": "容器日志"
@@ -1456,8 +1514,9 @@
"copied": "已复制",
"copyFailed": "复制失败",
"downloadFailed": "下载失败",
"deletedScanRecord": "删除扫描记录: {name}",
"deleteSuccess": "删除成功:{count} 条",
"deleteFailed": "删除失败,请重试",
"deletedScanRecord": "已删除扫描记录: {name}",
"stoppedScan": "已停止扫描任务: {name}",
"stopFailed": "停止失败,请重试",
"bulkDeleteSuccess": "已删除 {count} 个扫描记录",
@@ -2019,6 +2078,16 @@
"description": "常用的工具操作"
}
},
"screenshots": {
"filterPlaceholder": "按 URL 过滤...",
"loadError": "加载截图失败",
"noResults": "没有匹配的截图",
"lightboxTitle": "截图预览",
"empty": {
"title": "暂无截图",
"description": "启用截图功能进行站点扫描后,截图将显示在这里。"
}
},
"targetDetail": {
"noDescription": "暂无描述",
"breadcrumb": {
@@ -2035,6 +2104,7 @@
"tabs": {
"overview": "概览",
"assets": "资产",
"screenshots": "截图",
"vulnerabilities": "漏洞",
"settings": "设置"
},

View File

@@ -0,0 +1,69 @@
/**
* Blacklist Mock Data
*
* 黑名单规则 mock 数据
* - 全局黑名单:适用于所有 Target
* - Target 黑名单:仅适用于特定 Target
*/
export interface BlacklistResponse {
patterns: string[]
}
export interface UpdateBlacklistRequest {
patterns: string[]
}
// 全局黑名单 mock 数据
let mockGlobalBlacklistPatterns: string[] = [
'*.gov',
'*.edu',
'*.mil',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
]
// Target 黑名单 mock 数据(按 targetId 存储)
const mockTargetBlacklistPatterns: Record<number, string[]> = {
1: ['*.internal.example.com', '192.168.1.0/24'],
2: ['cdn.example.com', '*.cdn.*'],
}
/**
* 获取全局黑名单
*/
export function getMockGlobalBlacklist(): BlacklistResponse {
return {
patterns: [...mockGlobalBlacklistPatterns],
}
}
/**
* 更新全局黑名单(全量替换)
*/
export function updateMockGlobalBlacklist(data: UpdateBlacklistRequest): BlacklistResponse {
mockGlobalBlacklistPatterns = [...data.patterns]
return {
patterns: mockGlobalBlacklistPatterns,
}
}
/**
* 获取 Target 黑名单
*/
export function getMockTargetBlacklist(targetId: number): BlacklistResponse {
return {
patterns: mockTargetBlacklistPatterns[targetId] ? [...mockTargetBlacklistPatterns[targetId]] : [],
}
}
/**
* 更新 Target 黑名单(全量替换)
*/
export function updateMockTargetBlacklist(targetId: number, data: UpdateBlacklistRequest): BlacklistResponse {
mockTargetBlacklistPatterns[targetId] = [...data.patterns]
return {
patterns: mockTargetBlacklistPatterns[targetId],
}
}

View File

@@ -182,3 +182,11 @@ export {
getMockNotificationSettings,
updateMockNotificationSettings,
} from './data/notification-settings'
// Blacklist
export {
getMockGlobalBlacklist,
updateMockGlobalBlacklist,
getMockTargetBlacklist,
updateMockTargetBlacklist,
} from './data/blacklist'

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
@@ -39,6 +40,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@radix-ui/react-visually-hidden": "^1.2.3",
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-table": "^8.21.3",

View File

@@ -47,6 +47,9 @@ importers:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@radix-ui/react-hover-card':
specifier: ^1.1.6
version: 1.1.15(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@19.1.2)
@@ -92,6 +95,9 @@ importers:
'@radix-ui/react-use-controllable-state':
specifier: ^1.2.2
version: 1.2.2(@types/react@19.2.0)(react@19.1.2)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@tabler/icons-react':
specifier: ^3.35.0
version: 3.35.0(react@19.1.2)
@@ -929,6 +935,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-hover-card@1.1.15':
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-icons@1.3.2':
resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==}
peerDependencies:
@@ -4106,6 +4125,23 @@ snapshots:
'@types/react': 19.2.0
'@types/react-dom': 19.2.0(@types/react@19.2.0)
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.0)(react@19.1.2)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.0)(react@19.1.2)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.0)(react@19.1.2)
react: 19.1.2
react-dom: 19.1.2(react@19.1.2)
optionalDependencies:
'@types/react': 19.2.0
'@types/react-dom': 19.2.0(@types/react@19.2.0)
'@radix-ui/react-icons@1.3.2(react@19.1.2)':
dependencies:
react: 19.1.2

View File

@@ -6,8 +6,25 @@ export interface BulkCreateDirectoriesResponse {
createdCount: number
}
// Bulk delete response type
export interface BulkDeleteResponse {
deletedCount: number
}
/** Directory related API service */
export class DirectoryService {
/**
* Bulk delete directories
* POST /api/assets/directories/bulk-delete/
*/
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/directories/bulk-delete/`,
{ ids }
)
return response.data
}
/**
* Bulk create directories (bind to target)
* POST /api/targets/{target_id}/directories/bulk-create/

View File

@@ -16,8 +16,25 @@ export interface BulkCreateEndpointsResponse {
createdCount: number
}
// Bulk delete response type
export interface BulkDeleteResponse {
deletedCount: number
}
export class EndpointService {
/**
* Bulk delete endpoints
* POST /api/assets/endpoints/bulk-delete/
*/
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/endpoints/bulk-delete/`,
{ ids }
)
return response.data
}
/**
* Bulk create endpoints (bind to target)
* POST /api/targets/{target_id}/endpoints/bulk-create/

View File

@@ -1,4 +1,5 @@
import { api } from '@/lib/api-client'
import { USE_MOCK, mockDelay, getMockGlobalBlacklist, updateMockGlobalBlacklist } from '@/mock'
export interface GlobalBlacklistResponse {
patterns: string[]
@@ -12,6 +13,10 @@ export interface UpdateGlobalBlacklistRequest {
* Get global blacklist rules
*/
export async function getGlobalBlacklist(): Promise<GlobalBlacklistResponse> {
if (USE_MOCK) {
await mockDelay()
return getMockGlobalBlacklist()
}
const res = await api.get<GlobalBlacklistResponse>('/blacklist/rules/')
return res.data
}
@@ -20,6 +25,10 @@ export async function getGlobalBlacklist(): Promise<GlobalBlacklistResponse> {
* Update global blacklist rules (full replace)
*/
export async function updateGlobalBlacklist(data: UpdateGlobalBlacklistRequest): Promise<GlobalBlacklistResponse> {
if (USE_MOCK) {
await mockDelay()
return updateMockGlobalBlacklist(data)
}
const res = await api.put<GlobalBlacklistResponse>('/blacklist/rules/', data)
return res.data
}

View File

@@ -1,7 +1,25 @@
import { api } from "@/lib/api-client"
import type { GetIPAddressesParams, GetIPAddressesResponse } from "@/types/ip-address.types"
// Bulk delete response type
export interface BulkDeleteResponse {
deletedCount: number
}
export class IPAddressService {
/**
* Bulk delete IP addresses
* POST /api/assets/ip-addresses/bulk-delete/
* Note: IP addresses are aggregated, so we pass IP strings instead of IDs
*/
static async bulkDelete(ips: string[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/ip-addresses/bulk-delete/`,
{ ips }
)
return response.data
}
static async getTargetIPAddresses(
targetId: number,
params?: GetIPAddressesParams

View File

@@ -0,0 +1,94 @@
import { api } from "@/lib/api-client"
// Screenshot type
export interface Screenshot {
id: number
url: string
statusCode: number | null
createdAt: string
updatedAt: string
}
// Screenshot snapshot type (for scan results)
export interface ScreenshotSnapshot {
id: number
url: string
statusCode: number | null
createdAt: string
}
// Paginated response
export interface PaginatedResponse<T> {
results: T[]
total: number
page: number
pageSize: number
totalPages: number
}
// Bulk delete response
export interface BulkDeleteResponse {
deletedCount: number
}
/**
* Screenshot related API service
*/
export class ScreenshotService {
/**
* Get screenshots by target
* GET /api/targets/{target_id}/screenshots/
*/
static async getByTarget(
targetId: number,
params?: { page?: number; pageSize?: number; filter?: string }
): Promise<PaginatedResponse<Screenshot>> {
const response = await api.get<PaginatedResponse<Screenshot>>(
`/targets/${targetId}/screenshots/`,
{ params }
)
return response.data
}
/**
* Get screenshot image URL
* Returns the URL to fetch the image binary
*/
static getImageUrl(screenshotId: number): string {
return `/api/assets/screenshots/${screenshotId}/image/`
}
/**
* Get screenshot snapshots by scan
* GET /api/scans/{scan_id}/screenshots/
*/
static async getByScan(
scanId: number,
params?: { page?: number; pageSize?: number; filter?: string }
): Promise<PaginatedResponse<ScreenshotSnapshot>> {
const response = await api.get<PaginatedResponse<ScreenshotSnapshot>>(
`/scans/${scanId}/screenshots/`,
{ params }
)
return response.data
}
/**
* Get screenshot snapshot image URL
*/
static getSnapshotImageUrl(scanId: number, snapshotId: number): string {
return `/api/scans/${scanId}/screenshots/${snapshotId}/image/`
}
/**
* Bulk delete screenshots
* POST /api/assets/screenshots/bulk-delete/
*/
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/screenshots/bulk-delete/`,
{ ids }
)
return response.data
}
}

View File

@@ -12,7 +12,7 @@ import type {
BatchCreateTargetsRequest,
BatchCreateTargetsResponse,
} from '@/types/target.types'
import { USE_MOCK, mockDelay, getMockTargets, getMockTargetById } from '@/mock'
import { USE_MOCK, mockDelay, getMockTargets, getMockTargetById, getMockTargetBlacklist, updateMockTargetBlacklist } from '@/mock'
/**
* Get all targets list (paginated)
@@ -163,6 +163,10 @@ export async function getTargetEndpoints(
* Get target's blacklist rules
*/
export async function getTargetBlacklist(id: number): Promise<{ patterns: string[] }> {
if (USE_MOCK) {
await mockDelay()
return getMockTargetBlacklist(id)
}
const response = await api.get<{ patterns: string[] }>(`/targets/${id}/blacklist/`)
return response.data
}
@@ -174,6 +178,11 @@ export async function updateTargetBlacklist(
id: number,
patterns: string[]
): Promise<{ count: number }> {
if (USE_MOCK) {
await mockDelay()
const result = updateMockTargetBlacklist(id, { patterns })
return { count: result.patterns.length }
}
const response = await api.put<{ count: number }>(`/targets/${id}/blacklist/`, { patterns })
return response.data
}

View File

@@ -6,11 +6,27 @@ export interface BulkCreateWebsitesResponse {
createdCount: number
}
// Bulk delete response type
export interface BulkDeleteResponse {
deletedCount: number
}
/**
* Website related API service
* All frontend website interface calls should be centralized here
*/
export class WebsiteService {
/**
* Bulk delete websites
* POST /api/assets/websites/bulk-delete/
*/
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
const response = await api.post<BulkDeleteResponse>(
`/assets/websites/bulk-delete/`,
{ ids }
)
return response.data
}
/**
* Bulk create websites (bind to target)
* POST /api/targets/{target_id}/websites/bulk-create/

View File

@@ -59,6 +59,7 @@ export interface ScanRecord {
progress: number // 0-100
currentStage?: ScanStage // Current scan stage (only has value in running status)
stageProgress?: StageProgress // Stage progress details
yamlConfiguration?: string // YAML configuration string
}
export interface GetScansParams {