From d5359e1f04ac8a439e4e65c2f2d63a28ea72bd44 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:06:22 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B5=84=E4=BA=A7=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=89=A7=E8=A1=8C=E9=93=BE=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BB=BB=E5=8A=A1=E7=BA=A7=E6=8B=A6=E6=88=AA/?= =?UTF-8?q?=E5=85=81=E8=AE=B8=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 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 导致误关抽屉);任务详情总览新增规则管理卡片(增删改+启用开关)。 --- agent/tools.go | 15 + agent/tools_insert.go | 74 +++++ db/asset_intercept.go | 3 + db/asset_intercept_match.go | 250 ++++++++++++++++ db/asset_intercept_match_test.go | 129 ++++++++ db/schema.sql | 29 ++ db/task_intercept.go | 122 ++++++++ db/tasks.go | 5 + server/server.go | 14 + server/task_intercept.go | 189 ++++++++++++ .../tasks/detail/_tabs/overview-tab.tsx | 275 ++++++++++++++++++ web/src/app/(main)/function/tasks/page.tsx | 101 +++++++ web/src/lib/api.ts | 15 + web/src/lib/types.ts | 13 + 14 files changed, 1234 insertions(+) create mode 100644 db/asset_intercept_match.go create mode 100644 db/asset_intercept_match_test.go create mode 100644 db/task_intercept.go create mode 100644 server/task_intercept.go diff --git a/agent/tools.go b/agent/tools.go index 141d5d9..621b27c 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -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 diff --git a/agent/tools_insert.go b/agent/tools_insert.go index e844360..273d55c 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -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 diff --git a/db/asset_intercept.go b/db/asset_intercept.go index 32771c7..7b4940d 100644 --- a/db/asset_intercept.go +++ b/db/asset_intercept.go @@ -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"` } diff --git a/db/asset_intercept_match.go b/db/asset_intercept_match.go new file mode 100644 index 0000000..460d796 --- /dev/null +++ b/db/asset_intercept_match.go @@ -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 +} diff --git a/db/asset_intercept_match_test.go b/db/asset_intercept_match_test.go new file mode 100644 index 0000000..deb58c4 --- /dev/null +++ b/db/asset_intercept_match_test.go @@ -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) + } +} diff --git a/db/schema.sql b/db/schema.sql index 97a4d04..a3fe24c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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(); diff --git a/db/task_intercept.go b/db/task_intercept.go new file mode 100644 index 0000000..45501ff --- /dev/null +++ b/db/task_intercept.go @@ -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 +} diff --git a/db/tasks.go b/db/tasks.go index 69a1a34..26a82f8 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -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 { diff --git a/server/server.go b/server/server.go index 9883729..7f804f3 100644 --- a/server/server.go +++ b/server/server.go @@ -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) { diff --git a/server/task_intercept.go b/server/task_intercept.go new file mode 100644 index 0000000..73b209e --- /dev/null +++ b/server/task_intercept.go @@ -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}) +} diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index d7babef..e18d24e 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -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 }) { )} + {coverage && coverage.enabled && coverage.scope_rows > 0 && ( @@ -1045,3 +1049,274 @@ export function OverviewTab({ taskId }: { taskId: string }) { ); } + +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 = Object.fromEntries( + TASK_RULE_KIND_OPTIONS.map((o) => [o.value, o.label]), +) as Record; + +// TaskInterceptRulesCard 在任务详情总览里管理「任务级资产拦截 / 允许规则」: +// 列表 + 新增 + 行内编辑 + 删除 + 启用开关。规则仅本任务生效,不进全局表。 +function TaskInterceptRulesCard({ taskId }: { taskId: string }) { + const [rules, setRules] = React.useState([]); + const [busy, setBusy] = React.useState(false); + const [err, setErr] = React.useState(""); + const [newAction, setNewAction] = React.useState<"block" | "allow">("block"); + const [newKind, setNewKind] = React.useState("fuzzy_domain"); + const [newPattern, setNewPattern] = React.useState(""); + const [newNote, setNewNote] = React.useState(""); + const [editId, setEditId] = React.useState(null); + const [editAction, setEditAction] = React.useState<"block" | "allow">("block"); + const [editKind, setEditKind] = React.useState("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 ( + + + + 任务级资产拦截 / 允许 + + (仅本任务生效,不进全局;先拦截后允许,共 {rules.length} 条) + + + + + {/* 新增表单 */} +
+ setNewAction(e.target.value as "block" | "allow")}> + 拦截 + 允许 + + setNewKind(e.target.value as AssetInterceptKind)}> + {TASK_RULE_KIND_OPTIONS.map((o) => ( + + {o.label} + + ))} + + setNewPattern(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void add(); + }} + disabled={busy} + /> + setNewNote(e.target.value)} + disabled={busy} + /> + + {err && {err}} +
+ {/* 规则列表 */} + {rules.length > 0 ? ( +
+ {rules.map((r) => + editId === r.id ? ( +
+ setEditAction(e.target.value as "block" | "allow")} + > + 拦截 + 允许 + + setEditKind(e.target.value as AssetInterceptKind)} + > + {TASK_RULE_KIND_OPTIONS.map((o) => ( + + {o.label} + + ))} + + setEditPattern(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void saveEdit(r); + if (e.key === "Escape") setEditId(null); + }} + disabled={busy} + autoFocus + /> + setEditNote(e.target.value)} + disabled={busy} + /> + + +
+ ) : ( +
+ + {r.action === "allow" ? "允许" : "拦截"} + + {TASK_RULE_KIND_LABEL[r.kind]} + {r.pattern} + {r.note && ( + {r.note} + )} + void toggle(r)} /> + + +
+ ), + )} +
+ ) : ( +

+ 暂无任务级规则。「拦截」命中即禁止测试;「允许」为白名单——配置后本任务只允许命中允许规则的资产(未配置则不启用白名单)。 +

+ )} +
+
+ ); +} diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index a5637bf..4dcb0bf 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -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) { + 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 ( +
+ {value.map((r, i) => { + const ph = TASK_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? ""; + return ( +
+ update(i, { action: e.target.value as "block" | "allow" })} + > + 拦截 + 允许 + + update(i, { kind: e.target.value as AssetInterceptKind })} + > + {TASK_INTERCEPT_KIND_OPTIONS.map((o) => ( + + {o.label} + + ))} + + update(i, { pattern: e.target.value })} + /> + update(i, { note: e.target.value })} + /> + +
+ ); + })} + +
+ ); +} + 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([]); // 任务级资产拦截规则(仅本任务生效,不进全局表) // 方式1 文件上传:建任务前把文件暂存到 drafts//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({ 作为范围上下文;不会自动生成意图或强制改变执行目标。 + + 任务级资产拦截 / 允许规则(可选) + + + 仅对本任务生效,不写入全局规则。判定顺序:先按「拦截」规则(含全局)匹配,命中即禁止测试;未命中且本任务配置了「允许」规则时,须命中某条允许规则才放行,否则同样不允许测试;未配置任何允许规则则不启用白名单。 + + LLM 配置链 post("/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(`/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(`/tasks/${id}/intercept-rules`, rule), + updateTaskInterceptRule: (id: string, ruleId: number, rule: AssetInterceptRuleInput) => + put(`/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)。 diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 1c16b89..9fe7fa5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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;