feat: 资产拦截接入执行链,新增任务级拦截/允许规则
在 agent 下发意图(add_intent)与插入资产(insert_assets)两处接入资产闸门;
并新增任务级规则(拦截 block + 允许 allow 白名单),创建任务时录入、任务详情
总览里可编辑。
- 匹配/执行(db/asset_intercept_match.go):MatchAssetInterceptRules 纯匹配
(exact/fuzzy 的 域名·IP·URL + CIDR,URL host 会拆出归类);EvaluateAssetGate
「先拦截后允许」闸门——命中拦截即禁止;未命中但存在启用的允许规则且都不命中→
不允许测试;无启用允许规则则不启用白名单。附单元测试。
- 执行点:add_intent 命中即不下发意图;insert_assets 命中即跳过 Upsert 及
AddAutoScope 副作用。均合并 全局∪任务级 规则。
- 任务级规则:表 task_intercept_rules(task_id FK CASCADE + action block/allow,
幂等 ALTER 补旧库);db/task_intercept.go 提供 List/Split/CRUD/事务插入;
创建任务随 intercept_rules 提交;CRUD 路由 /api/tasks/{id}/intercept-rules。
- 前端:新建任务抽屉多行录入(拦截/允许 + 类型,用 NativeSelect 避免 Sheet 内
下拉 portal 导致误关抽屉);任务详情总览新增规则管理卡片(增删改+启用开关)。
This commit is contained in:
1 parent
dfc9d2fa2e
commit
d5359e1f04
14 files changed
+1234
No files matched your search
@@ -1211,6 +1211,21 @@ func (t *ToolSet) addOneIntent(it intentItem) (int64, error) {
|
||||
priority = 5
|
||||
}
|
||||
anchors := pidList(it.AssetIDs)
|
||||
// 资产拦截:意图绑定的资产若命中系统资产拦截规则,则禁止下发该意图。
|
||||
if t.as != nil && len(anchors) > 0 {
|
||||
hits, err := t.as.CheckAssetsIntercept(t.taskID, anchors)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("资产拦截校验失败:%w", err)
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "意图「%s」绑定的资产未通过测试范围校验,请停止对相关资产进行测试:", it.Summary)
|
||||
for _, h := range hits {
|
||||
fmt.Fprintf(&b, "\n - %s", h.Describe())
|
||||
}
|
||||
return 0, fmt.Errorf("%s", b.String())
|
||||
}
|
||||
}
|
||||
payload := map[string]any{"summary": it.Summary}
|
||||
if len(anchors) > 0 {
|
||||
payload["asset_ids"] = anchors
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -11,6 +13,57 @@ import (
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// assetInterceptCandidates 提取一条待插入资产输入项的 域名/IP/URL 候选串,用于资产拦截匹配。
|
||||
// URL 的 host 会拆出归类,使「只带 URL」的服务/端点资产也能被 域名/IP 规则命中。
|
||||
func assetInterceptCandidates(item assetInputItem) (domains, ips, urls []string) {
|
||||
add := func(dst *[]string, s string) {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
*dst = append(*dst, s)
|
||||
}
|
||||
}
|
||||
add(&domains, item.Domain)
|
||||
for _, d := range item.BoundDomains {
|
||||
add(&domains, d)
|
||||
}
|
||||
add(&ips, item.IP)
|
||||
add(&ips, item.ServiceIP)
|
||||
add(&urls, item.URL)
|
||||
if item.URL != "" {
|
||||
if u, err := url.Parse(item.URL); err == nil {
|
||||
if h := u.Hostname(); h != "" {
|
||||
if net.ParseIP(h) != nil {
|
||||
add(&ips, h)
|
||||
} else {
|
||||
add(&domains, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return domains, ips, urls
|
||||
}
|
||||
|
||||
// assetInputLabel 返回一条待插入资产的简短标识,用于拦截说明消息。
|
||||
func assetInputLabel(item assetInputItem) string {
|
||||
typ := strings.TrimSpace(item.Type)
|
||||
var target string
|
||||
switch {
|
||||
case strings.TrimSpace(item.Domain) != "":
|
||||
target = strings.TrimSpace(item.Domain)
|
||||
case strings.TrimSpace(item.URL) != "":
|
||||
target = strings.TrimSpace(item.URL)
|
||||
case strings.TrimSpace(item.IP) != "":
|
||||
target = strings.TrimSpace(item.IP)
|
||||
case strings.TrimSpace(item.ServiceIP) != "":
|
||||
target = strings.TrimSpace(item.ServiceIP)
|
||||
default:
|
||||
target = "(未知)"
|
||||
}
|
||||
if typ != "" {
|
||||
return fmt.Sprintf("[%s] %s", typ, target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Unified asset insertion tools
|
||||
// =====================================================================
|
||||
@@ -174,7 +227,28 @@ func (t *ToolSet) insertAssets() actool.CoreTool {
|
||||
var results []result
|
||||
var errs []errEntry
|
||||
|
||||
// 资产闸门规则一次性载入;读取失败则跳过判定(不阻断插入)。
|
||||
// 拦截规则 = 全局 ∪ 任务级 block;允许规则 = 任务级 allow。
|
||||
blockRules, _ := t.as.ListAssetInterceptRules()
|
||||
var allowRules []db.AssetInterceptRule
|
||||
if t.taskID > 0 {
|
||||
if tb, ta, err := t.as.TaskInterceptRulesSplit(t.taskID); err == nil {
|
||||
blockRules = append(blockRules, tb...)
|
||||
allowRules = ta
|
||||
}
|
||||
}
|
||||
|
||||
for i, item := range a.Assets {
|
||||
// 资产闸门:先拦截后允许,被拒的资产禁止插入(跳过 Upsert 及后续副作用)。
|
||||
domains, ips, urls := assetInterceptCandidates(item)
|
||||
if d := db.EvaluateAssetGate(blockRules, allowRules, domains, ips, urls); !d.Allowed {
|
||||
errs = append(errs, errEntry{
|
||||
Index: i,
|
||||
Error: fmt.Sprintf("资产 %s %s,已禁止插入", assetInputLabel(item), d.Reason),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
typ := strings.TrimSpace(item.Type)
|
||||
var id int64
|
||||
var err error
|
||||
|
||||
@@ -13,6 +13,9 @@ type AssetInterceptRule struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Note string `json:"note"`
|
||||
Builtin bool `json:"builtin"`
|
||||
// Action 仅用于任务级规则:'block'=拦截 'allow'=允许(白名单)。
|
||||
// 全局规则(asset_intercept_rules)不带此列,恒为空,视为拦截。
|
||||
Action string `json:"action,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 资产拦截规则的匹配/执行层。asset_intercept.go 只负责规则存储,这里负责把
|
||||
// 「目标资产」的域名/IP/URL 与启用中的规则做匹配。供 agent 工具(add_intent、
|
||||
// insert_assets)在下发意图 / 插入资产前调用,命中则拒绝。
|
||||
|
||||
// AssetInterceptKindLabel 返回 kind 的中文标签,用于给 agent 的说明消息。
|
||||
func AssetInterceptKindLabel(kind string) string {
|
||||
switch kind {
|
||||
case "exact_domain":
|
||||
return "域名(全等)"
|
||||
case "exact_ip":
|
||||
return "IP(全等)"
|
||||
case "exact_url":
|
||||
return "URL(全等)"
|
||||
case "fuzzy_domain":
|
||||
return "域名(模糊)"
|
||||
case "fuzzy_ip":
|
||||
return "IP(模糊)"
|
||||
case "fuzzy_url":
|
||||
return "URL(模糊)"
|
||||
case "cidr":
|
||||
return "CIDR 网段"
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
// Reason 返回一条可读的命中原因,形如:命中资产拦截规则 [域名(模糊): .gov.cn](备注)。
|
||||
func (r AssetInterceptRule) Reason() string {
|
||||
s := fmt.Sprintf("命中资产拦截规则 [%s: %s]", AssetInterceptKindLabel(r.Kind), r.Pattern)
|
||||
if note := strings.TrimSpace(r.Note); note != "" {
|
||||
s += "(" + note + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// matchOne 判断单条启用规则是否命中给定的域名/IP/URL 候选串,返回命中的具体值。
|
||||
func matchOne(r AssetInterceptRule, domains, ips, urls []string) (string, bool) {
|
||||
p := strings.TrimSpace(r.Pattern)
|
||||
if p == "" {
|
||||
return "", false
|
||||
}
|
||||
switch r.Kind {
|
||||
case "exact_domain":
|
||||
for _, d := range domains {
|
||||
if strings.EqualFold(strings.TrimSpace(d), p) {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
case "exact_ip":
|
||||
for _, ip := range ips {
|
||||
if strings.TrimSpace(ip) == p {
|
||||
return ip, true
|
||||
}
|
||||
}
|
||||
case "exact_url":
|
||||
for _, u := range urls {
|
||||
if strings.TrimSpace(u) == p {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
case "fuzzy_domain":
|
||||
lp := strings.ToLower(p)
|
||||
for _, d := range domains {
|
||||
if d != "" && strings.Contains(strings.ToLower(d), lp) {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
case "fuzzy_ip":
|
||||
for _, ip := range ips {
|
||||
if ip != "" && strings.Contains(ip, p) {
|
||||
return ip, true
|
||||
}
|
||||
}
|
||||
case "fuzzy_url":
|
||||
lp := strings.ToLower(p)
|
||||
for _, u := range urls {
|
||||
if u != "" && strings.Contains(strings.ToLower(u), lp) {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
case "cidr":
|
||||
_, ipnet, err := net.ParseCIDR(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if pip := net.ParseIP(strings.TrimSpace(ip)); pip != nil && ipnet.Contains(pip) {
|
||||
return ip, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// MatchAssetInterceptRules 返回第一条命中给定 域名/IP/URL 候选串的启用规则,及命中的具体值。
|
||||
// 供 insert_assets 用原始输入(尚未落库的 assetInputItem)匹配。
|
||||
func MatchAssetInterceptRules(rules []AssetInterceptRule, domains, ips, urls []string) (AssetInterceptRule, string, bool) {
|
||||
for _, r := range rules {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
if v, ok := matchOne(r, domains, ips, urls); ok {
|
||||
return r, v, true
|
||||
}
|
||||
}
|
||||
return AssetInterceptRule{}, "", false
|
||||
}
|
||||
|
||||
// interceptCandidates 提取一个已落库资产用于拦截匹配的 域名/IP/URL 候选串。
|
||||
// URL 的 host 会被拆出并归类,使「只带 URL」的服务类资产也能被 域名/IP 规则命中。
|
||||
func (a *Asset) interceptCandidates() (domains, ips, urls []string) {
|
||||
add := func(dst *[]string, s string) {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
*dst = append(*dst, s)
|
||||
}
|
||||
}
|
||||
add(&domains, a.Domain)
|
||||
add(&domains, a.RootDomain)
|
||||
for _, d := range a.BoundDomains {
|
||||
add(&domains, d)
|
||||
}
|
||||
add(&ips, a.IP)
|
||||
add(&urls, a.URL)
|
||||
if a.URL != "" {
|
||||
if u, err := url.Parse(a.URL); err == nil {
|
||||
if h := u.Hostname(); h != "" {
|
||||
if net.ParseIP(h) != nil {
|
||||
add(&ips, h)
|
||||
} else {
|
||||
add(&domains, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return domains, ips, urls
|
||||
}
|
||||
|
||||
// InterceptLabel 返回资产的简短标识,用于给 agent 的说明消息。
|
||||
func (a *Asset) InterceptLabel() string {
|
||||
var target string
|
||||
switch {
|
||||
case a.Domain != "":
|
||||
target = a.Domain
|
||||
case a.URL != "":
|
||||
target = a.URL
|
||||
case a.IP != "":
|
||||
target = a.IP
|
||||
default:
|
||||
target = fmt.Sprintf("#%d", a.ID)
|
||||
}
|
||||
return fmt.Sprintf("资产#%d[%s] %s", a.ID, a.Type, target)
|
||||
}
|
||||
|
||||
// hasEnabledRule 判断规则集里是否存在任一启用规则。
|
||||
func hasEnabledRule(rules []AssetInterceptRule) bool {
|
||||
for _, r := range rules {
|
||||
if r.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AssetGateDecision 是「先拦截后允许」闸门对一组候选串的判定结果。
|
||||
type AssetGateDecision struct {
|
||||
Allowed bool
|
||||
Reason string // 被拒原因(不含资产标识);Allowed=true 时为空
|
||||
}
|
||||
|
||||
// EvaluateAssetGate 执行任务级闸门判定:
|
||||
// 1. 命中任一启用的 blockRules → 拒绝(拦截原因)。
|
||||
// 2. 否则若 allowRules 存在启用项且都不命中 → 拒绝(不在允许范围)。
|
||||
// 3. 否则放行。
|
||||
//
|
||||
// allowRules 为空/无启用项时,允许闸门不生效(即不启用白名单,全部放行),
|
||||
// 避免「未配置允许规则」把所有资产挡掉。
|
||||
func EvaluateAssetGate(blockRules, allowRules []AssetInterceptRule, domains, ips, urls []string) AssetGateDecision {
|
||||
if rule, _, ok := MatchAssetInterceptRules(blockRules, domains, ips, urls); ok {
|
||||
return AssetGateDecision{Allowed: false, Reason: rule.Reason()}
|
||||
}
|
||||
if hasEnabledRule(allowRules) {
|
||||
if _, _, ok := MatchAssetInterceptRules(allowRules, domains, ips, urls); !ok {
|
||||
return AssetGateDecision{Allowed: false, Reason: "不在任务允许(白名单)范围内,不允许测试"}
|
||||
}
|
||||
}
|
||||
return AssetGateDecision{Allowed: true}
|
||||
}
|
||||
|
||||
// AssetInterceptHit 描述一个被闸门拒绝的资产(拦截命中 或 不在允许范围)。
|
||||
type AssetInterceptHit struct {
|
||||
Asset *Asset
|
||||
Reason string // 可读原因
|
||||
}
|
||||
|
||||
// Describe 返回一条可读的说明:资产信息 + 原因。
|
||||
func (h AssetInterceptHit) Describe() string {
|
||||
return fmt.Sprintf("%s → %s", h.Asset.InterceptLabel(), h.Reason)
|
||||
}
|
||||
|
||||
// ListAssetInterceptRules 是 *DB 同名方法的透传,让只持有 AssetStore 的调用方
|
||||
// (如 agent 工具)也能读取规则。
|
||||
func (s *AssetStore) ListAssetInterceptRules() ([]AssetInterceptRule, error) {
|
||||
return s.db.ListAssetInterceptRules()
|
||||
}
|
||||
|
||||
// CheckAssetsIntercept 按 id 载入资产,逐个执行「先拦截后允许」闸门判定,返回所有
|
||||
// 被拒的资产。拦截规则 = 全局 ∪ 任务级 block;允许规则 = 任务级 allow(仅本任务)。
|
||||
// 无 id 时快速返回。用全局 GetByIDs(不受任务范围过滤)以保证拦截不被 scope 削弱。
|
||||
func (s *AssetStore) CheckAssetsIntercept(taskID int64, ids []int64) ([]AssetInterceptHit, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
blockRules, err := s.db.ListAssetInterceptRules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var allowRules []AssetInterceptRule
|
||||
if taskID > 0 {
|
||||
tb, ta, err := s.TaskInterceptRulesSplit(taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blockRules = append(blockRules, tb...)
|
||||
allowRules = ta
|
||||
}
|
||||
// 既无拦截规则、也无启用的允许规则 → 无需判定,全部放行。
|
||||
if len(blockRules) == 0 && !hasEnabledRule(allowRules) {
|
||||
return nil, nil
|
||||
}
|
||||
assets, err := s.GetByIDs(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var hits []AssetInterceptHit
|
||||
for _, a := range assets {
|
||||
domains, ips, urls := a.interceptCandidates()
|
||||
if d := EvaluateAssetGate(blockRules, allowRules, domains, ips, urls); !d.Allowed {
|
||||
hits = append(hits, AssetInterceptHit{Asset: a, Reason: d.Reason})
|
||||
}
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
func rule(kind, pattern string, enabled bool) AssetInterceptRule {
|
||||
return AssetInterceptRule{Kind: kind, Pattern: pattern, Enabled: enabled}
|
||||
}
|
||||
|
||||
func TestMatchAssetInterceptRules(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rules []AssetInterceptRule
|
||||
domains []string
|
||||
ips []string
|
||||
urls []string
|
||||
want bool
|
||||
wantVal string
|
||||
}{
|
||||
{"内置模糊政府域名命中", []AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)},
|
||||
[]string{"www.beijing.gov.cn"}, nil, nil, true, "www.beijing.gov.cn"},
|
||||
{"模糊教育域名命中", []AssetInterceptRule{rule("fuzzy_domain", ".edu", true)},
|
||||
[]string{"mit.edu"}, nil, nil, true, "mit.edu"},
|
||||
{"全等域名命中大小写不敏感", []AssetInterceptRule{rule("exact_domain", "Example.com", true)},
|
||||
[]string{"example.com"}, nil, nil, true, "example.com"},
|
||||
{"全等域名不命中子域", []AssetInterceptRule{rule("exact_domain", "example.com", true)},
|
||||
[]string{"a.example.com"}, nil, nil, false, ""},
|
||||
{"全等IP命中", []AssetInterceptRule{rule("exact_ip", "203.0.113.5", true)},
|
||||
nil, []string{"203.0.113.5"}, nil, true, "203.0.113.5"},
|
||||
{"模糊IP前缀命中", []AssetInterceptRule{rule("fuzzy_ip", "203.0.113.", true)},
|
||||
nil, []string{"203.0.113.99"}, nil, true, "203.0.113.99"},
|
||||
{"CIDR 命中", []AssetInterceptRule{rule("cidr", "192.168.0.0/16", true)},
|
||||
nil, []string{"192.168.5.20"}, nil, true, "192.168.5.20"},
|
||||
{"CIDR 不命中", []AssetInterceptRule{rule("cidr", "192.168.0.0/16", true)},
|
||||
nil, []string{"10.0.0.1"}, nil, false, ""},
|
||||
{"全等URL命中", []AssetInterceptRule{rule("exact_url", "https://a.gov.cn/login", true)},
|
||||
nil, nil, []string{"https://a.gov.cn/login"}, true, "https://a.gov.cn/login"},
|
||||
{"模糊URL命中路径", []AssetInterceptRule{rule("fuzzy_url", "/admin", true)},
|
||||
nil, nil, []string{"https://x.com/admin/panel"}, true, "https://x.com/admin/panel"},
|
||||
{"禁用规则不命中", []AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", false)},
|
||||
[]string{"www.gov.cn"}, nil, nil, false, ""},
|
||||
{"无规则不命中", nil, []string{"www.gov.cn"}, nil, nil, false, ""},
|
||||
{"空pattern不命中", []AssetInterceptRule{rule("fuzzy_domain", " ", true)},
|
||||
[]string{"www.gov.cn"}, nil, nil, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r, val, ok := MatchAssetInterceptRules(c.rules, c.domains, c.ips, c.urls)
|
||||
if ok != c.want {
|
||||
t.Fatalf("命中 = %v, 期望 %v (rule=%+v)", ok, c.want, r)
|
||||
}
|
||||
if ok && val != c.wantVal {
|
||||
t.Fatalf("命中值 = %q, 期望 %q", val, c.wantVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAssetGate(t *testing.T) {
|
||||
block := []AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)}
|
||||
allow := []AssetInterceptRule{rule("fuzzy_domain", "example.com", true)}
|
||||
|
||||
// 1. 命中拦截规则 → 拒绝(拦截原因优先)。
|
||||
if d := EvaluateAssetGate(block, allow, []string{"www.gov.cn"}, nil, nil); d.Allowed {
|
||||
t.Fatal("命中拦截规则应被拒绝")
|
||||
}
|
||||
|
||||
// 2. 未命中拦截、有允许规则但不命中 → 拒绝(不允许)。
|
||||
d := EvaluateAssetGate(block, allow, []string{"foo.other.com"}, nil, nil)
|
||||
if d.Allowed {
|
||||
t.Fatal("有白名单且不命中应被拒绝")
|
||||
}
|
||||
if d.Reason == "" {
|
||||
t.Fatal("拒绝应带原因")
|
||||
}
|
||||
|
||||
// 3. 未命中拦截、命中允许规则 → 放行。
|
||||
if d := EvaluateAssetGate(block, allow, []string{"api.example.com"}, nil, nil); !d.Allowed {
|
||||
t.Fatal("命中白名单应放行")
|
||||
}
|
||||
|
||||
// 4. 无允许规则(白名单未启用)→ 未命中拦截即放行。
|
||||
if d := EvaluateAssetGate(block, nil, []string{"foo.other.com"}, nil, nil); !d.Allowed {
|
||||
t.Fatal("无白名单时未命中拦截应放行")
|
||||
}
|
||||
|
||||
// 5. 允许规则全部禁用 → 视为白名单未启用,放行。
|
||||
disabledAllow := []AssetInterceptRule{rule("fuzzy_domain", "example.com", false)}
|
||||
if d := EvaluateAssetGate(nil, disabledAllow, []string{"foo.other.com"}, nil, nil); !d.Allowed {
|
||||
t.Fatal("白名单全禁用时应放行")
|
||||
}
|
||||
|
||||
// 6. 拦截优先于允许:同一目标既命中拦截又命中允许 → 拒绝。
|
||||
if d := EvaluateAssetGate(
|
||||
[]AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)},
|
||||
[]AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)},
|
||||
[]string{"www.gov.cn"}, nil, nil,
|
||||
); d.Allowed {
|
||||
t.Fatal("拦截应优先于允许")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetInterceptCandidates(t *testing.T) {
|
||||
// 只带 URL 的服务资产:host 应被拆出并归入域名候选,从而被 fuzzy_domain 命中。
|
||||
a := &Asset{Type: "service", URL: "https://portal.beijing.gov.cn:8443/app"}
|
||||
domains, _, urls := a.interceptCandidates()
|
||||
if len(urls) != 1 || urls[0] != a.URL {
|
||||
t.Fatalf("urls = %v", urls)
|
||||
}
|
||||
found := false
|
||||
for _, d := range domains {
|
||||
if d == "portal.beijing.gov.cn" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("URL host 未拆入域名候选: %v", domains)
|
||||
}
|
||||
r, _, ok := MatchAssetInterceptRules([]AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)}, domains, nil, urls)
|
||||
if !ok {
|
||||
t.Fatalf("仅带 URL 的政府服务资产应被 fuzzy_domain 命中, rule=%+v", r)
|
||||
}
|
||||
|
||||
// URL host 是 IP 时应归入 IP 候选,可被 CIDR 命中。
|
||||
b := &Asset{Type: "service", URL: "http://10.1.2.3/x"}
|
||||
_, ips, _ := b.interceptCandidates()
|
||||
if r, _, ok := MatchAssetInterceptRules([]AssetInterceptRule{rule("cidr", "10.0.0.0/8", true)}, nil, ips, nil); !ok {
|
||||
t.Fatalf("URL 中的 IP 应被 CIDR 命中, ips=%v rule=%+v", ips, r)
|
||||
}
|
||||
}
|
||||
@@ -1248,3 +1248,32 @@ CREATE INDEX IF NOT EXISTS idx_asset_intercept_enabled ON asset_intercept_rules(
|
||||
DROP TRIGGER IF EXISTS trg_asset_intercept_rules_upd ON asset_intercept_rules;
|
||||
CREATE TRIGGER trg_asset_intercept_rules_upd BEFORE UPDATE ON asset_intercept_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- 任务级资产拦截/允许规则
|
||||
-- 与全局 asset_intercept_rules 同构(kind/pattern/note/enabled),但按 task_id
|
||||
-- 关联、随任务级联删除;创建任务时录入、任务详情里可编辑。
|
||||
-- action: 'block'=拦截(禁止测试) 'allow'=允许(白名单)。
|
||||
-- 执行判定:先按 拦截规则(全局 ∪ 任务block) 匹配,命中即禁止;未命中且该任务存在
|
||||
-- 启用的 allow 规则时,须命中某条 allow 才放行,否则「不允许测试」。
|
||||
-- =====================================================================
|
||||
CREATE TABLE IF NOT EXISTS task_intercept_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
action TEXT NOT NULL DEFAULT 'block' CHECK (action IN ('block','allow')),
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'exact_domain', 'exact_ip', 'exact_url',
|
||||
'fuzzy_domain', 'fuzzy_ip', 'fuzzy_url',
|
||||
'cidr')),
|
||||
pattern TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_intercept_task ON task_intercept_rules(task_id);
|
||||
-- 补旧库(本会话早前建过该表、无 action 列):加列(带 IF NOT EXISTS)。
|
||||
ALTER TABLE task_intercept_rules ADD COLUMN IF NOT EXISTS action TEXT NOT NULL DEFAULT 'block';
|
||||
DROP TRIGGER IF EXISTS trg_task_intercept_rules_upd ON task_intercept_rules;
|
||||
CREATE TRIGGER trg_task_intercept_rules_upd BEFORE UPDATE ON task_intercept_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
@@ -0,0 +1,122 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// TaskInterceptRuleInput is one task-level rule supplied at task creation.
|
||||
// Action: 'block'=拦截 'allow'=允许(白名单);空视为 'block'。
|
||||
type TaskInterceptRuleInput struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Action string `json:"action"`
|
||||
Kind string `json:"kind"`
|
||||
Pattern string `json:"pattern"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
const taskInterceptRuleCols = `id, enabled, action, kind, pattern, note, created_at, updated_at`
|
||||
|
||||
func scanTaskInterceptRule(row interface{ Scan(...any) error }) (AssetInterceptRule, error) {
|
||||
var r AssetInterceptRule
|
||||
err := row.Scan(&r.ID, &r.Enabled, &r.Action, &r.Kind, &r.Pattern, &r.Note, &r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
|
||||
// ListTaskInterceptRules returns a task's rules (both block and allow) as
|
||||
// AssetInterceptRule (Builtin always false; Action carries block/allow). taskID
|
||||
// <= 0 returns nothing.
|
||||
func (s *AssetStore) ListTaskInterceptRules(taskID int64) ([]AssetInterceptRule, error) {
|
||||
if taskID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT `+taskInterceptRuleCols+` FROM task_intercept_rules WHERE task_id=$1 ORDER BY action, id`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AssetInterceptRule
|
||||
for rows.Next() {
|
||||
r, err := scanTaskInterceptRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TaskInterceptRulesSplit loads a task's rules and splits them into block and
|
||||
// allow sets, for the enforcement gate.
|
||||
func (s *AssetStore) TaskInterceptRulesSplit(taskID int64) (block, allow []AssetInterceptRule, err error) {
|
||||
rules, err := s.ListTaskInterceptRules(taskID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r.Action == "allow" {
|
||||
allow = append(allow, r)
|
||||
} else {
|
||||
block = append(block, r)
|
||||
}
|
||||
}
|
||||
return block, allow, nil
|
||||
}
|
||||
|
||||
func normalizeRuleAction(action string) string {
|
||||
if action == "allow" {
|
||||
return "allow"
|
||||
}
|
||||
return "block"
|
||||
}
|
||||
|
||||
// CreateTaskInterceptRule inserts a rule under a task.
|
||||
func (s *AssetStore) CreateTaskInterceptRule(taskID int64, action, kind, pattern, note string, enabled bool) (AssetInterceptRule, error) {
|
||||
row := s.db.QueryRow(`
|
||||
INSERT INTO task_intercept_rules(task_id, enabled, action, kind, pattern, note)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING `+taskInterceptRuleCols,
|
||||
taskID, enabled, normalizeRuleAction(action), kind, pattern, note)
|
||||
return scanTaskInterceptRule(row)
|
||||
}
|
||||
|
||||
// UpdateTaskInterceptRule replaces the editable fields of a task's rule (scoped
|
||||
// by task_id so a rule can only be edited through its owning task).
|
||||
func (s *AssetStore) UpdateTaskInterceptRule(taskID, ruleID int64, action, kind, pattern, note string, enabled bool) (AssetInterceptRule, error) {
|
||||
row := s.db.QueryRow(`
|
||||
UPDATE task_intercept_rules
|
||||
SET enabled=$3, action=$4, kind=$5, pattern=$6, note=$7
|
||||
WHERE id=$1 AND task_id=$2
|
||||
RETURNING `+taskInterceptRuleCols,
|
||||
ruleID, taskID, enabled, normalizeRuleAction(action), kind, pattern, note)
|
||||
return scanTaskInterceptRule(row)
|
||||
}
|
||||
|
||||
// DeleteTaskInterceptRule removes a task's rule. Returns false if not found.
|
||||
func (s *AssetStore) DeleteTaskInterceptRule(taskID, ruleID int64) (bool, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM task_intercept_rules WHERE id=$1 AND task_id=$2`, ruleID, taskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ToggleTaskInterceptRule flips the enabled state of a task's rule.
|
||||
func (s *AssetStore) ToggleTaskInterceptRule(taskID, ruleID int64, enabled bool) error {
|
||||
_, err := s.db.Exec(`UPDATE task_intercept_rules SET enabled=$3 WHERE id=$1 AND task_id=$2`, ruleID, taskID, enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
// insertTaskInterceptRules inserts task-level rules within the task-creation
|
||||
// transaction (mirrors insertTaskCompanies).
|
||||
func insertTaskInterceptRules(tx *sql.Tx, taskID int64, rules []TaskInterceptRuleInput) error {
|
||||
for _, r := range rules {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO task_intercept_rules(task_id, enabled, action, kind, pattern, note)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`, taskID, r.Enabled, normalizeRuleAction(r.Action), r.Kind, r.Pattern, r.Note); err != nil {
|
||||
return fmt.Errorf("insert task intercept rule %q: %w", r.Pattern, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -154,6 +154,8 @@ type TaskCreateOptions struct {
|
||||
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
|
||||
// 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。
|
||||
CoverageEnabled *bool
|
||||
// InterceptRules 是任务级资产拦截规则,创建时随任务在同一事务内写入 task_intercept_rules。
|
||||
InterceptRules []TaskInterceptRuleInput
|
||||
}
|
||||
|
||||
// CreateTaskWithOptions creates an exploration, task, direct source relations,
|
||||
@@ -238,6 +240,9 @@ RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, descripti
|
||||
if err := insertTaskLLMProfiles(tx, t.ID, opts.LLMProfileIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertTaskInterceptRules(tx, t.ID, opts.InterceptRules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(opts.LLMProfileIDs) == 0 {
|
||||
t.LLMFailoverState = "default"
|
||||
} else {
|
||||
|
||||
@@ -680,6 +680,12 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
|
||||
mux.HandleFunc("PATCH /api/tasks/{id}", s.updateTaskMetadata)
|
||||
mux.HandleFunc("PATCH /api/tasks/{id}/category", s.updateTaskCategory)
|
||||
// 任务级资产拦截/允许规则
|
||||
mux.HandleFunc("GET /api/tasks/{id}/intercept-rules", s.taskInterceptListRules)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intercept-rules", s.taskInterceptCreateRule)
|
||||
mux.HandleFunc("PUT /api/tasks/{id}/intercept-rules/{rid}", s.taskInterceptUpdateRule)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/intercept-rules/{rid}", s.taskInterceptDeleteRule)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intercept-rules/{rid}/toggle", s.taskInterceptToggleRule)
|
||||
mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch)
|
||||
mux.HandleFunc("GET /api/task-archives", s.listTaskArchives)
|
||||
mux.HandleFunc("GET /api/task-archives/{id}", s.getTaskArchive)
|
||||
@@ -1427,6 +1433,8 @@ type createTaskReq struct {
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
|
||||
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
|
||||
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
|
||||
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
|
||||
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1473,11 +1481,17 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
req.CompanyIDs = companyIDs
|
||||
interceptRules, err := buildTaskInterceptRules(req.InterceptRules)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "任务级拦截规则无效:"+err.Error())
|
||||
return
|
||||
}
|
||||
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
|
||||
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
|
||||
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
|
||||
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
|
||||
CoverageEnabled: req.CoverageEnabled,
|
||||
InterceptRules: interceptRules,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// 任务级资产拦截/允许规则的 CRUD。规则按 task_id 归属,仅对该任务生效:
|
||||
// action=block 拦截(禁止测试),action=allow 允许(白名单)。执行判定见 db.EvaluateAssetGate。
|
||||
|
||||
type taskInterceptRuleReq struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Action string `json:"action"` // block | allow
|
||||
Kind string `json:"kind"`
|
||||
Pattern string `json:"pattern"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// validateTaskInterceptRuleReq 归一并校验;复用全局规则的 kind/pattern 校验器。
|
||||
func validateTaskInterceptRuleReq(req *taskInterceptRuleReq) error {
|
||||
if req.Action == "" {
|
||||
req.Action = "block"
|
||||
}
|
||||
if req.Action != "block" && req.Action != "allow" {
|
||||
return fmt.Errorf("action 必须是 block 或 allow")
|
||||
}
|
||||
v := assetInterceptRuleReq{Enabled: req.Enabled, Kind: req.Kind, Pattern: req.Pattern, Note: req.Note}
|
||||
if err := validateAssetInterceptRuleReq(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Pattern = v.Pattern // 已 trim
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTaskInterceptRules 校验创建任务时录入的任务级规则并转换为 db 输入形态。
|
||||
func buildTaskInterceptRules(reqs []taskInterceptRuleReq) ([]db.TaskInterceptRuleInput, error) {
|
||||
if len(reqs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]db.TaskInterceptRuleInput, 0, len(reqs))
|
||||
for i := range reqs {
|
||||
rq := reqs[i]
|
||||
if err := validateTaskInterceptRuleReq(&rq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, db.TaskInterceptRuleInput{
|
||||
Enabled: rq.Enabled,
|
||||
Action: rq.Action,
|
||||
Kind: rq.Kind,
|
||||
Pattern: rq.Pattern,
|
||||
Note: rq.Note,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) taskInterceptListRules(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
taskID, ok := pathInt(r, "id")
|
||||
if !ok || taskID <= 0 {
|
||||
writeErr(w, 400, "bad task id")
|
||||
return
|
||||
}
|
||||
rules, err := pg.Assets().ListTaskInterceptRules(taskID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if rules == nil {
|
||||
rules = []db.AssetInterceptRule{}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"rules": rules})
|
||||
}
|
||||
|
||||
func (s *Server) taskInterceptCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
taskID, ok := pathInt(r, "id")
|
||||
if !ok || taskID <= 0 {
|
||||
writeErr(w, 400, "bad task id")
|
||||
return
|
||||
}
|
||||
var req taskInterceptRuleReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateTaskInterceptRuleReq(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
rule, err := pg.Assets().CreateTaskInterceptRule(taskID, req.Action, req.Kind, req.Pattern, req.Note, req.Enabled)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, rule)
|
||||
}
|
||||
|
||||
func (s *Server) taskInterceptUpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
taskID, ok := pathInt(r, "id")
|
||||
if !ok || taskID <= 0 {
|
||||
writeErr(w, 400, "bad task id")
|
||||
return
|
||||
}
|
||||
ruleID, ok := pathInt(r, "rid")
|
||||
if !ok || ruleID <= 0 {
|
||||
writeErr(w, 400, "bad rule id")
|
||||
return
|
||||
}
|
||||
var req taskInterceptRuleReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateTaskInterceptRuleReq(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
rule, err := pg.Assets().UpdateTaskInterceptRule(taskID, ruleID, req.Action, req.Kind, req.Pattern, req.Note, req.Enabled)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, rule)
|
||||
}
|
||||
|
||||
func (s *Server) taskInterceptDeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
taskID, ok := pathInt(r, "id")
|
||||
if !ok || taskID <= 0 {
|
||||
writeErr(w, 400, "bad task id")
|
||||
return
|
||||
}
|
||||
ruleID, ok := pathInt(r, "rid")
|
||||
if !ok || ruleID <= 0 {
|
||||
writeErr(w, 400, "bad rule id")
|
||||
return
|
||||
}
|
||||
deleted, err := pg.Assets().DeleteTaskInterceptRule(taskID, ruleID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"deleted": deleted})
|
||||
}
|
||||
|
||||
func (s *Server) taskInterceptToggleRule(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
taskID, ok := pathInt(r, "id")
|
||||
if !ok || taskID <= 0 {
|
||||
writeErr(w, 400, "bad task id")
|
||||
return
|
||||
}
|
||||
ruleID, ok := pathInt(r, "rid")
|
||||
if !ok || ruleID <= 0 {
|
||||
writeErr(w, 400, "bad rule id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := pg.Assets().ToggleTaskInterceptRule(taskID, ruleID, req.Enabled); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "enabled": req.Enabled})
|
||||
}
|
||||
@@ -26,8 +26,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
AssetInterceptKind,
|
||||
AssetInterceptRule,
|
||||
Finding,
|
||||
ModelTokenStat,
|
||||
Stats,
|
||||
@@ -697,6 +700,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TaskInterceptRulesCard taskId={taskId} />
|
||||
{coverage && coverage.enabled && coverage.scope_rows > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1045,3 +1049,274 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TASK_RULE_KIND_OPTIONS: { value: AssetInterceptKind; label: string; placeholder: string }[] = [
|
||||
{ value: "exact_domain", label: "域名(全等)", placeholder: "example.gov.cn" },
|
||||
{ value: "exact_ip", label: "IP(全等)", placeholder: "203.0.113.10" },
|
||||
{ value: "exact_url", label: "URL(全等)", placeholder: "https://example.com/login" },
|
||||
{ value: "fuzzy_domain", label: "域名(模糊)", placeholder: ".gov.cn" },
|
||||
{ value: "fuzzy_ip", label: "IP(模糊)", placeholder: "203.0.113." },
|
||||
{ value: "fuzzy_url", label: "URL(模糊)", placeholder: "/admin" },
|
||||
{ value: "cidr", label: "CIDR 网段", placeholder: "192.168.0.0/16" },
|
||||
];
|
||||
|
||||
const TASK_RULE_KIND_LABEL: Record<AssetInterceptKind, string> = Object.fromEntries(
|
||||
TASK_RULE_KIND_OPTIONS.map((o) => [o.value, o.label]),
|
||||
) as Record<AssetInterceptKind, string>;
|
||||
|
||||
// TaskInterceptRulesCard 在任务详情总览里管理「任务级资产拦截 / 允许规则」:
|
||||
// 列表 + 新增 + 行内编辑 + 删除 + 启用开关。规则仅本任务生效,不进全局表。
|
||||
function TaskInterceptRulesCard({ taskId }: { taskId: string }) {
|
||||
const [rules, setRules] = React.useState<AssetInterceptRule[]>([]);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [err, setErr] = React.useState("");
|
||||
const [newAction, setNewAction] = React.useState<"block" | "allow">("block");
|
||||
const [newKind, setNewKind] = React.useState<AssetInterceptKind>("fuzzy_domain");
|
||||
const [newPattern, setNewPattern] = React.useState("");
|
||||
const [newNote, setNewNote] = React.useState("");
|
||||
const [editId, setEditId] = React.useState<number | null>(null);
|
||||
const [editAction, setEditAction] = React.useState<"block" | "allow">("block");
|
||||
const [editKind, setEditKind] = React.useState<AssetInterceptKind>("fuzzy_domain");
|
||||
const [editPattern, setEditPattern] = React.useState("");
|
||||
const [editNote, setEditNote] = React.useState("");
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setRules(await api.taskInterceptRules(taskId));
|
||||
} catch {
|
||||
// 忽略瞬时错误
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function add() {
|
||||
if (!newPattern.trim()) return;
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
try {
|
||||
await api.createTaskInterceptRule(taskId, {
|
||||
action: newAction,
|
||||
kind: newKind,
|
||||
pattern: newPattern.trim(),
|
||||
note: newNote.trim(),
|
||||
enabled: true,
|
||||
});
|
||||
setNewPattern("");
|
||||
setNewNote("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(r: AssetInterceptRule) {
|
||||
setEditId(r.id);
|
||||
setEditAction(r.action ?? "block");
|
||||
setEditKind(r.kind);
|
||||
setEditPattern(r.pattern);
|
||||
setEditNote(r.note);
|
||||
setErr("");
|
||||
}
|
||||
|
||||
async function saveEdit(r: AssetInterceptRule) {
|
||||
if (!editPattern.trim()) return;
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
try {
|
||||
await api.updateTaskInterceptRule(taskId, r.id, {
|
||||
action: editAction,
|
||||
kind: editKind,
|
||||
pattern: editPattern.trim(),
|
||||
note: editNote.trim(),
|
||||
enabled: r.enabled,
|
||||
});
|
||||
setEditId(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: AssetInterceptRule) {
|
||||
setRules((prev) => prev.filter((x) => x.id !== r.id));
|
||||
try {
|
||||
await api.deleteTaskInterceptRule(taskId, r.id);
|
||||
} catch {
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(r: AssetInterceptRule) {
|
||||
setRules((prev) => prev.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)));
|
||||
try {
|
||||
await api.toggleTaskInterceptRule(taskId, r.id, !r.enabled);
|
||||
} catch {
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
const placeholder = TASK_RULE_KIND_OPTIONS.find((o) => o.value === newKind)?.placeholder ?? "";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheckIcon className="size-4 text-sky-500" /> 任务级资产拦截 / 允许
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(仅本任务生效,不进全局;先拦截后允许,共 {rules.length} 条)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{/* 新增表单 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect size="sm" value={newAction} onChange={(e) => setNewAction(e.target.value as "block" | "allow")}>
|
||||
<NativeSelectOption value="block">拦截</NativeSelectOption>
|
||||
<NativeSelectOption value="allow">允许</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<NativeSelect size="sm" value={newKind} onChange={(e) => setNewKind(e.target.value as AssetInterceptKind)}>
|
||||
{TASK_RULE_KIND_OPTIONS.map((o) => (
|
||||
<NativeSelectOption key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Input
|
||||
className="h-7 min-w-56 flex-1 text-sm"
|
||||
placeholder={placeholder}
|
||||
value={newPattern}
|
||||
onChange={(e) => setNewPattern(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void add();
|
||||
}}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-36 text-sm"
|
||||
placeholder="备注(可选)"
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={busy || !newPattern.trim()} onClick={() => void add()}>
|
||||
<PlusIcon className="size-3.5" /> 添加
|
||||
</Button>
|
||||
{err && <span className="text-xs text-red-500">{err}</span>}
|
||||
</div>
|
||||
{/* 规则列表 */}
|
||||
{rules.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{rules.map((r) =>
|
||||
editId === r.id ? (
|
||||
<div key={r.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
value={editAction}
|
||||
onChange={(e) => setEditAction(e.target.value as "block" | "allow")}
|
||||
>
|
||||
<NativeSelectOption value="block">拦截</NativeSelectOption>
|
||||
<NativeSelectOption value="allow">允许</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
value={editKind}
|
||||
onChange={(e) => setEditKind(e.target.value as AssetInterceptKind)}
|
||||
>
|
||||
{TASK_RULE_KIND_OPTIONS.map((o) => (
|
||||
<NativeSelectOption key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Input
|
||||
className="h-7 min-w-56 flex-1 text-sm"
|
||||
value={editPattern}
|
||||
onChange={(e) => setEditPattern(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveEdit(r);
|
||||
if (e.key === "Escape") setEditId(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-36 text-sm"
|
||||
placeholder="备注(可选)"
|
||||
value={editNote}
|
||||
onChange={(e) => setEditNote(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={busy || !editPattern.trim()}
|
||||
onClick={() => void saveEdit(r)}
|
||||
>
|
||||
<CheckIcon className="size-3.5 text-emerald-500" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={busy}
|
||||
onClick={() => setEditId(null)}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div key={r.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-0.5 text-xs ${
|
||||
r.action === "allow"
|
||||
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-red-500/15 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{r.action === "allow" ? "允许" : "拦截"}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{TASK_RULE_KIND_LABEL[r.kind]}</span>
|
||||
<code className="bg-muted min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-xs">{r.pattern}</code>
|
||||
{r.note && (
|
||||
<span className="text-muted-foreground max-w-[120px] shrink-0 truncate text-xs">{r.note}</span>
|
||||
)}
|
||||
<Switch checked={r.enabled} onCheckedChange={() => void toggle(r)} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={busy}
|
||||
onClick={() => startEdit(r)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(r)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
暂无任务级规则。「拦截」命中即禁止测试;「允许」为白名单——配置后本任务只允许命中允许规则的资产(未配置则不启用白名单)。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -121,6 +122,8 @@ import { api } from "@/lib/api";
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference";
|
||||
import type {
|
||||
AssetInterceptKind,
|
||||
AssetInterceptRuleInput,
|
||||
ChatAttachment,
|
||||
Company,
|
||||
DeleteTaskOptions,
|
||||
@@ -3032,6 +3035,92 @@ function CategoryManagementSheet({
|
||||
);
|
||||
}
|
||||
|
||||
const TASK_INTERCEPT_KIND_OPTIONS: { value: AssetInterceptKind; label: string; placeholder: string }[] = [
|
||||
{ value: "exact_domain", label: "域名(全等)", placeholder: "example.gov.cn" },
|
||||
{ value: "exact_ip", label: "IP(全等)", placeholder: "203.0.113.10" },
|
||||
{ value: "exact_url", label: "URL(全等)", placeholder: "https://example.com/login" },
|
||||
{ value: "fuzzy_domain", label: "域名(模糊)", placeholder: ".gov.cn" },
|
||||
{ value: "fuzzy_ip", label: "IP(模糊)", placeholder: "203.0.113." },
|
||||
{ value: "fuzzy_url", label: "URL(模糊)", placeholder: "/admin" },
|
||||
{ value: "cidr", label: "CIDR 网段", placeholder: "192.168.0.0/16" },
|
||||
];
|
||||
|
||||
// TaskInterceptRulesField 是创建任务时录入「任务级资产拦截规则」的多行编辑区。
|
||||
// 规则只对本任务生效、不进全局表;与全局规则合并后在 agent 执行时匹配。
|
||||
function TaskInterceptRulesField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: AssetInterceptRuleInput[];
|
||||
onChange: (v: AssetInterceptRuleInput[]) => void;
|
||||
}) {
|
||||
function update(i: number, patch: Partial<AssetInterceptRuleInput>) {
|
||||
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function remove(i: number) {
|
||||
onChange(value.filter((_, idx) => idx !== i));
|
||||
}
|
||||
function add() {
|
||||
onChange([...value, { action: "block", kind: "fuzzy_domain", pattern: "", note: "", enabled: true }]);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{value.map((r, i) => {
|
||||
const ph = TASK_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? "";
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
className="w-[84px] shrink-0"
|
||||
value={r.action}
|
||||
onChange={(e) => update(i, { action: e.target.value as "block" | "allow" })}
|
||||
>
|
||||
<NativeSelectOption value="block">拦截</NativeSelectOption>
|
||||
<NativeSelectOption value="allow">允许</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
className="w-[120px] shrink-0"
|
||||
value={r.kind}
|
||||
onChange={(e) => update(i, { kind: e.target.value as AssetInterceptKind })}
|
||||
>
|
||||
{TASK_INTERCEPT_KIND_OPTIONS.map((o) => (
|
||||
<NativeSelectOption key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={ph}
|
||||
value={r.pattern}
|
||||
onChange={(e) => update(i, { pattern: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="w-[120px] shrink-0"
|
||||
placeholder="备注(可选)"
|
||||
value={r.note}
|
||||
onChange={(e) => update(i, { note: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive size-8 shrink-0"
|
||||
onClick={() => remove(i)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button type="button" size="sm" variant="outline" className="w-fit" onClick={add}>
|
||||
<PlusIcon className="size-4" /> 添加一条
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTaskSheet({
|
||||
tasks,
|
||||
categories,
|
||||
@@ -3059,6 +3148,7 @@ function CreateTaskSheet({
|
||||
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
|
||||
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
|
||||
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
|
||||
const [interceptRules, setInterceptRules] = React.useState<AssetInterceptRuleInput[]>([]); // 任务级资产拦截规则(仅本任务生效,不进全局表)
|
||||
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
const [uploadCount, setUploadCount] = React.useState(0);
|
||||
@@ -3125,6 +3215,9 @@ function CreateTaskSheet({
|
||||
seedFirstIntent,
|
||||
planHeartbeatSeconds: heartbeatSec,
|
||||
coverageEnabled,
|
||||
interceptRules: interceptRules
|
||||
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
|
||||
.filter((r) => r.pattern !== ""),
|
||||
});
|
||||
toast.success("任务已创建");
|
||||
setName("");
|
||||
@@ -3139,6 +3232,7 @@ function CreateTaskSheet({
|
||||
setHeartbeatMin("10");
|
||||
setSeedFirstIntent(false);
|
||||
setCoverageEnabled(true);
|
||||
setInterceptRules([]);
|
||||
setUploadCount(0);
|
||||
draftIdRef.current = "";
|
||||
setOpen(false);
|
||||
@@ -3274,6 +3368,13 @@ function CreateTaskSheet({
|
||||
作为范围上下文;不会自动生成意图或强制改变执行目标。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-intercept-rules">任务级资产拦截 / 允许规则(可选)</FieldLabel>
|
||||
<TaskInterceptRulesField value={interceptRules} onChange={setInterceptRules} />
|
||||
<FieldDescription>
|
||||
仅对本任务生效,不写入全局规则。判定顺序:先按「拦截」规则(含全局)匹配,命中即禁止测试;未命中且本任务配置了「允许」规则时,须命中某条允许规则才放行,否则同样不允许测试;未配置任何允许规则则不启用白名单。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="llm-profiles">LLM 配置链</FieldLabel>
|
||||
<TaskLLMProfileChain
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ArchiveBatchItem,
|
||||
Asset,
|
||||
AssetInterceptRule,
|
||||
AssetInterceptRuleInput,
|
||||
Audit,
|
||||
BatchCategoryItem,
|
||||
BatchControlItem,
|
||||
@@ -243,6 +244,7 @@ export const api = {
|
||||
seedFirstIntent?: boolean;
|
||||
planHeartbeatSeconds?: number;
|
||||
coverageEnabled?: boolean;
|
||||
interceptRules?: AssetInterceptRuleInput[];
|
||||
}) =>
|
||||
post<Task>("/tasks", {
|
||||
name: input.name ?? "",
|
||||
@@ -256,6 +258,7 @@ export const api = {
|
||||
seed_first_intent: input.seedFirstIntent ?? false,
|
||||
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
||||
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
|
||||
}),
|
||||
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
|
||||
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch<Task>(`/tasks/${id}`, input),
|
||||
@@ -388,6 +391,18 @@ export const api = {
|
||||
deleteConstraint: (id: string, constraintId: string) =>
|
||||
del<{ ok: boolean }>(`/tasks/${id}/constraints/${constraintId}`),
|
||||
|
||||
// ---- 任务级资产拦截/允许规则(总览)----
|
||||
taskInterceptRules: (id: string) =>
|
||||
get<{ rules: AssetInterceptRule[] | null }>(`/tasks/${id}/intercept-rules`).then((r) => arr(r.rules)),
|
||||
createTaskInterceptRule: (id: string, rule: AssetInterceptRuleInput) =>
|
||||
post<AssetInterceptRule>(`/tasks/${id}/intercept-rules`, rule),
|
||||
updateTaskInterceptRule: (id: string, ruleId: number, rule: AssetInterceptRuleInput) =>
|
||||
put<AssetInterceptRule>(`/tasks/${id}/intercept-rules/${ruleId}`, rule),
|
||||
deleteTaskInterceptRule: (id: string, ruleId: number) =>
|
||||
del<{ deleted: boolean }>(`/tasks/${id}/intercept-rules/${ruleId}`),
|
||||
toggleTaskInterceptRule: (id: string, ruleId: number, enabled: boolean) =>
|
||||
post<{ ok: boolean; enabled: boolean }>(`/tasks/${id}/intercept-rules/${ruleId}/toggle`, { enabled }),
|
||||
|
||||
// 本任务测试范围列表(含继承自来源任务的范围)。
|
||||
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
|
||||
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
|
||||
|
||||
@@ -1201,9 +1201,22 @@ export type AssetInterceptKind =
|
||||
| "fuzzy_url"
|
||||
| "cidr";
|
||||
|
||||
// action 仅用于任务级规则:block=拦截(禁止测试) allow=允许(白名单)。
|
||||
export type AssetInterceptAction = "block" | "allow";
|
||||
|
||||
// 任务级资产拦截/允许规则的录入项(创建任务、任务详情编辑使用)。
|
||||
export interface AssetInterceptRuleInput {
|
||||
action: AssetInterceptAction;
|
||||
kind: AssetInterceptKind;
|
||||
pattern: string;
|
||||
note: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AssetInterceptRule {
|
||||
id: number;
|
||||
enabled: boolean;
|
||||
action?: AssetInterceptAction; // 全局规则不带此字段(恒为拦截);任务级规则区分 block/allow
|
||||
kind: AssetInterceptKind;
|
||||
pattern: string;
|
||||
note: string;
|
||||
|
||||
Reference in new issue
Block a user