Compare commits

...

14 Commits

Author SHA1 Message Date
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
17 changed files with 357 additions and 80 deletions

View File

@@ -69,6 +69,12 @@
- **自定义流程** - YAML 配置扫描流程,灵活编排
- **定时扫描** - Cron 表达式配置,自动化周期扫描
### 🚫 黑名单过滤
- **两层黑名单** - 全局黑名单 + Target 级黑名单,灵活控制扫描范围
- **智能规则识别** - 自动识别域名通配符(`*.gov`、IP、CIDR 网段
- **敏感目标保护** - 过滤政府、军事、教育等敏感域名,防止误扫
- **内网过滤** - 支持 `10.0.0.0/8``172.16.0.0/12``192.168.0.0/16` 等私有网段
### 🔖 指纹识别
- **多源指纹库** - 内置 EHole、Goby、Wappalyzer、Fingers、FingerPrintHub、ARL 等 2.7W+ 指纹规则
- **自动识别** - 扫描流程自动执行,识别 Web 应用技术栈

View File

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

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

@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,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

@@ -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

@@ -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
}