diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62475eb..4d82262 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,9 +55,10 @@ jobs: CGO_ENABLED: "0" run: | BIN=artex + VERSION="${GITHUB_REF_NAME#v}" [ "$GOOS" = "windows" ] && BIN=artex.exe go build -tags embedui -trimpath \ - -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \ + -ldflags "-s -w -X main.version=${VERSION}" \ -o "$BIN" ./cmd/artex - name: Package run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 83cd8ec..16680c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,28 +4,93 @@ ## [Unreleased] -### Added +暂无未发布变更。 -- 增加全局任务模板管理,新建任务可直接加载预设描述与目标,也可将当前内容另存为模板。 -- 新建任务支持关联多个企业资产范围;企业范围采用结构化规则,覆盖域名、IP、CIDR、ICP 和企业关键词。 -- 发现页按任务分组展示漏洞并支持组内独立分页;漏洞可提交带描述的高优先级深入意图,在原任务中继续验证。 -- 对话支持重命名、置顶和取消置顶;任务与 Worker 会话支持批量暂停、继续及逐项结果反馈。 -- 会话列表展示 Main Agent、Planner 和 Worker 下一次调用的模型,并提供完整历史 Token 聚合与高亮统计。 +## [0.3.3] - 2026-08-21 -### Changed +### Worker -- 任务列表移除单独的“进入”操作,改为通过可聚焦的任务标题链接进入详情。 -- 新增企业改用与任务、LLM 一致的右侧抽屉,并移除 Logo URL 输入项。 -- 任务创建、恢复、漏洞深入和队列补位统一使用持久化并发准入与 FIFO 调度路径。 -- 任务资产、企业资产、发现任务组及组内漏洞使用服务端真实分页、稳定排序和精确总数。 +#### 新增的功能 -### Fixed +- 运行中的 Worker 支持单项暂停、恢复和取消;暂停保留意图、事实与漏洞,取消会在执行退出后事务性清理当前意图及其直接产物。 +- 增加 `paused` 意图状态、执行栅栏和具名终止原因,防止暂停、取消或任务删除后出现迟到黑板写入。 +- 增加可选任务并发上限;新建、恢复、漏洞深入和队列补位统一通过持久化 FIFO 准入路径。 -- 修复 LLM 配置链耗尽错误在窄屏横向溢出,以及会话、对话头部和企业范围编辑在移动端的同类布局问题。 -- 修复跨任务同名漏洞节点可能联动编辑、删除或展开,以及删除末页数据后页码越界的问题。 -- 修复任务生命周期并发写入可能以旧快照覆盖终态、LLM 自动切换可能被旧配置快照回退的问题。 -- 修复 `timeout` 任务复活后仍被旧截止时间和收尾状态阻塞;复活后会保留时限配置并在下一次真实执行时重新计时。 -- 修复资产分页不稳定、极端页码 OFFSET 溢出、超大请求体状态码错误及部分存储层错误被吞掉的问题。 +#### 修改的功能 + +- Worker 单次运行墙钟默认时长由 600 秒调整为 1200 秒;超时任务复活后在下一次真实执行时重新计时。 +- Worker、Planner 和主 Agent 的真实执行状态统一驱动任务状态徽标,修复 Worker 运行时仍显示“空闲”的问题。 +- Worker 会话保留单项控制和当前会话 Token 汇总,模型名称移动到当前会话标题右侧展示。 + +#### 删除的功能 + +- 删除 Worker 多选、全选及批量暂停/继续界面,同时移除 Worker 批量控制 API 和 Mock 契约。 +- 删除 Worker 列表行中的 Token 徽标与 Tooltip,底层完整 Token 账本和任务聚合接口继续保留。 + +### LLM + +#### 新增的功能 + +- 任务支持有序 LLM 配置链;明确识别供应商额度不足时自动切换下一配置,并持久化当前配置、耗尽状态和错误摘要。 +- 运行中和暂停任务支持编辑完整配置链、调整顺序和手动切换当前配置,从下一次 LLM 调用生效。 +- 自动切换、手动切换和全链耗尽写入结构化系统活动,并通过任务活动流弹出可去重提醒。 +- 增加任务角色模型解析接口,统一解析 Main Agent、Planner 和 Worker 下一次调用使用的配置与模型。 + +#### 修改的功能 + +- 目标拆解、Planner、Worker 和主 Agent 共享任务级 LLM runtime;任务显式配置链优先于角色绑定和全局配置。 +- 故障转移只响应明确的额度、余额或账单错误;普通限流、鉴权、网络、服务端和上下文错误不会错误切换。 +- LLM 配置页改为配置卡片列表和右侧抽屉编辑,保留任务级配置链,不引入 Pool 轮询、优先级和熔断界面。 +- 配置链耗尽信息支持窄屏换行;当前模型由会话列表图标改为当前会话标题旁的文字标签和完整 Tooltip。 + +#### 删除的功能 + +- 删除会话列表中的 LLM 图标式标识,避免与 Worker 状态图标混淆。 + +### UI + +#### 新增的功能 + +- 增加全局任务模板 CRUD 和右侧管理抽屉;新建任务可加载预设描述与目标,也可将当前内容另存为模板。 +- 新建任务支持关联多个来源任务和多个企业资产范围;企业范围通过单个多行文本框自动识别域名、URL、IP、CIDR、ICP 和企业关键词。 +- 发现页按任务分组并提供组内独立分页,支持为漏洞填写描述并创建高优先级深入意图。 +- 对话支持重命名、置顶、取消置顶和删除;任务列表支持当前页批量暂停与继续。 +- 流量详情和工具执行详情改为右侧抽屉;HTTP 报文补齐 `Host` 并高亮请求行、状态码、Header、JSON 和标记语言正文。 + +#### 修改的功能 + +- 任务标题改为可聚焦详情链接;任务资产、企业资产、发现任务组和组内漏洞使用服务端真实分页、稳定排序与精确总数。 +- 新增企业改用右侧抽屉,范围录入统一为多行文本并提供实时识别、校验和类型预览。 +- 主 Agent 输入框支持自动增长、`Enter` 发送、`Shift+Enter` 换行,并避免中文输入法组合态误发送。 +- Agent 预览、任务报告和相关详情统一使用共享 Markdown 渲染;删除确认、长错误和移动端抽屉宽度统一修复。 +- 任务模板选择框以模板名称显示和搜索、以模板 ID 提交;恢复系统原始字体尺寸,应用版本统一显示为 `0.3.3`。 +- 任务总计和当前会话 Token 只高亮输入、缓存读取和输出数值;发送按钮使用更简洁的上箭头图标。 + +#### 删除的功能 + +- 删除任务卡片独立“进入”按钮,统一点击任务标题进入详情。 +- 删除仪表盘“新建任务”按钮、新增企业的 Logo URL 输入项,以及发现页顶部和任务分组中的漏洞数量 Badge。 +- 撤销全局字体增大 10% 的样式,并删除会话列表中的 Worker 选择框、模型图标和行级 Token 统计。 + +### Agent + +#### 新增的功能 + +- 新任务可直接关联多个已有任务,实时只读继承直接来源任务的目标、事实、漏洞、已完成意图、资产范围和黑板上下文。 +- 黑板读取工具支持按需查询来源任务节点、事实、漏洞和执行轨迹;继承节点带来源标记且所有写工具拒绝修改。 +- 企业范围工具支持域名、URL、IP、CIDR、ICP 和企业关键词;关键词仅作为 Agent 范围提示,不参与资产自动归属。 +- 漏洞深入操作会在原任务创建带资产锚点和 `derived_from` 边的高优先级人工 Worker 意图。 + +#### 修改的功能 + +- 来源任务意图不进入新任务 frontier,新任务继续使用独立 exploration、执行队列、工作目录和对话历史。 +- 主 Agent 对话改为服务端活动流驱动,发送失败可恢复输入;打开会话时自动贴底并在详情懒加载后保持最后回复可见。 +- 任务暂停会终止当前 Main Agent、Planner 和 Worker 调用,但不阻止暂停期间用户主动发起新的主 Agent 编排会话。 +- 取消、关闭和流式中断统一保留真实终止原因、已生成内容、运行轮数、耗时、Token 与未返回工具调用。 + +#### 删除的功能 + +- 删除主 Agent 客户端乐观消息回显,避免暂停、失败或并发活动下出现重复消息和跨会话串流。 ## [0.3.2] - 2026-08-20 @@ -68,5 +133,6 @@ - [@Autumn-27](https://github.com/Autumn-27) - [@neouks](https://github.com/neouks) -[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...HEAD +[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.3...HEAD +[0.3.3]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2 diff --git a/agent/tools_insert.go b/agent/tools_insert.go index f2b943c..6a318de 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -293,14 +293,14 @@ func (t *ToolSet) insertAssets() actool.CoreTool { func (t *ToolSet) addCompanyScope() actool.CoreTool { return writeTool( "add_company_scope", - "把根域名/IP/IP段(CIDR)加入某公司的【资产范围】——加入后系统自动认领所有命中的资产(现有+未来)。\n"+ + "把域名/IP/CIDR/ICP备案/企业关键词加入某公司的【资产范围】——域名、网络和ICP会自动认领命中的资产,关键词只提供给Agent作为范围提示。\n"+ "公司名唯一:company 不存在则新建,已存在则复用(只把范围并进去)。\n"+ - "scope 一行一条,支持:根域名 / 单个 IP / CIDR 网段(如 1.2.3.0/24)。\n"+ + "scope 一行一条,系统自动识别:根域名 / URL / 单个 IP / CIDR 网段 / ICP备案 / 企业关键词。\n"+ "务必给 reason 说明归属依据(whois/证书/ASN 等)。\n"+ - "护栏:拒绝裸 TLD 与过宽网段(IPv4<=/16、IPv6<=/32),非法行会被跳过并在 errors 返回。", + "护栏:拒绝裸 TLD 与过宽网段(IPv4前缀需为/16-/32、IPv6前缀需为/32-/128),非法行会被跳过并在 errors 返回。", obj(map[string]any{ "company": str("公司名(不存在则新建、存在则复用;名称唯一)"), - "scope": str("资产范围,一行一条:根域名 / IP / CIDR"), + "scope": str("资产范围,一行一条:域名 / URL / IP / CIDR / ICP备案 / 企业关键词"), "reason": str("归属依据(证据/来源),务必填写"), "logo": str("公司图标 URL(可选;仅新建公司时生效)"), }, "company", "scope"), diff --git a/build.sh b/build.sh index 7d046cc..e43bbd3 100755 --- a/build.sh +++ b/build.sh @@ -4,7 +4,7 @@ # 可选环境变量: # ARTEX_TARGET_OS=linux 目标系统,默认当前系统 # ARTEX_TARGET_ARCH=amd64 目标架构,默认当前架构 -# ARTEX_BUILD_VERSION=v0.3.0 构建版本,默认取 git describe +# ARTEX_BUILD_VERSION=0.3.3 构建版本,默认使用当前应用版本 # ARTEX_OUTPUT=/path/to/artex 输出路径 # ARTEX_SKIP_NPM_CI=1 跳过 npm ci,复用现有依赖 # ARTEX_GOSUMDB=sum.golang.org Go checksum database @@ -24,13 +24,7 @@ ARTEX_GOSUMDB="${ARTEX_GOSUMDB:-sum.golang.org}" ARTEX_TARGET_OS="${ARTEX_TARGET_OS:-$(GOSUMDB="$ARTEX_GOSUMDB" go env GOOS)}" ARTEX_TARGET_ARCH="${ARTEX_TARGET_ARCH:-$(GOSUMDB="$ARTEX_GOSUMDB" go env GOARCH)}" -if [ -z "${ARTEX_BUILD_VERSION:-}" ]; then - if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - ARTEX_BUILD_VERSION="$(git describe --tags --always --dirty)" - else - ARTEX_BUILD_VERSION="dev" - fi -fi +ARTEX_BUILD_VERSION="${ARTEX_BUILD_VERSION:-0.3.3}" ARTEX_BINARY_NAME="artex" [ "$ARTEX_TARGET_OS" = "windows" ] && ARTEX_BINARY_NAME="artex.exe" diff --git a/cmd/artex/main.go b/cmd/artex/main.go index cbc5b42..6023245 100644 --- a/cmd/artex/main.go +++ b/cmd/artex/main.go @@ -21,9 +21,9 @@ import ( "github.com/Autumn-27/artex/server" ) -// version is the build version, injected at release time via -// -ldflags "-X main.version=". Defaults to "dev" for local builds. -var version = "dev" +// version is the application version. Release builds may override it via +// -ldflags "-X main.version=". +var version = "0.3.3" const banner = ` _ ____ _____ _______ __ diff --git a/db/companies.go b/db/companies.go index 8e73acc..e360002 100644 --- a/db/companies.go +++ b/db/companies.go @@ -323,8 +323,8 @@ func (s *CompanyStore) AddScope(companyID int64, lines []string, reason string) return s.AddScopeInputs(companyID, inputs, reason) } -// AddScopeInputs inserts structured scope rules. Empty kinds retain the legacy -// domain/IP/CIDR auto-detection used by AddScope. +// AddScopeInputs inserts structured scope rules. Empty kinds use the automatic +// CIDR/IP/ICP/domain/keyword classification used by AddScope. func (s *CompanyStore) AddScopeInputs(companyID int64, inputs []ScopeInput, reason string) (added, skipped, invalid int, errors []string) { added, skipped, invalid, validationErrors, err := s.AddScopeInputsChecked(companyID, inputs, reason) if err != nil { @@ -604,6 +604,11 @@ func (s *CompanyStore) UpdateScopeInputsChecked(companyID int64, inputs []ScopeI return 0, 0, nil, err } rules, invalid, errs := parseScopeInputs(inputs) + if invalid > 0 { + return 0, invalid, errs, &CompanyScopeValidationError{Message: fmt.Sprintf( + "企业范围包含 %d 条无效规则,未覆盖原有范围", invalid, + )} + } if err := validateParsedScopeBounds(rules); err != nil { return 0, invalid, errs, err } diff --git a/db/companies_test.go b/db/companies_test.go index 395c012..f1592c8 100644 --- a/db/companies_test.go +++ b/db/companies_test.go @@ -255,6 +255,23 @@ func TestUpdateScope(t *testing.T) { if len(scope) != 1 || scope[0].Domain != "new-domain.com" { t.Errorf("UpdateScope: expected new-domain.com only, got %+v", scope) } + + // Invalid replacement input must not turn a partial validation response into + // a destructive replacement of the existing rules. + added, invalid, validationErrors, err := cs.UpdateScopeInputsChecked(id, []ScopeInput{ + {Kind: "domain", Value: "co.uk"}, + }, "invalid replacement") + var validationErr *CompanyScopeValidationError + if added != 0 || invalid != 1 || len(validationErrors) != 1 || !errors.As(err, &validationErr) { + t.Fatalf("invalid replacement: added=%d invalid=%d validation=%v err=%v", added, invalid, validationErrors, err) + } + scope, err = cs.GetScope(id) + if err != nil { + t.Fatal(err) + } + if len(scope) != 1 || scope[0].Domain != "new-domain.com" { + t.Fatalf("invalid replacement changed existing scope: %+v", scope) + } } func TestUpdateScopeRollsBackOnInsertFailure(t *testing.T) { diff --git a/db/company_scope.go b/db/company_scope.go index df2b5fb..236afa3 100644 --- a/db/company_scope.go +++ b/db/company_scope.go @@ -3,14 +3,16 @@ package db import ( "fmt" "net" + "net/url" "strings" "unicode" + "golang.org/x/net/idna" "golang.org/x/net/publicsuffix" ) // ParsedScope is one parsed asset-scope entry. Its kind selects Domain, Net, or Value. -// Used internally by ParseScopeLine / ParseScopeLines (passed to CompanyStore). +// Used internally by the company scope parsers and CompanyStore. type ParsedScope struct { Kind string // "domain" | "ip" | "cidr" | "icp" | "keyword" Domain string // normalized registrable/root domain (kind=domain) @@ -20,7 +22,7 @@ type ParsedScope struct { } // ScopeInput is the structured API form for a company scope rule. Empty Kind -// preserves the legacy one-line auto detection for domain/IP/CIDR clients. +// uses the same automatic classification as the single-textarea UI. type ScopeInput struct { Kind string `json:"kind,omitempty"` Value string `json:"value"` @@ -41,13 +43,56 @@ func normalizeKeyword(value string) string { return strings.ToLower(strings.Join(strings.Fields(value), " ")) } +func looksLikeIPAddress(value string) bool { + value = strings.TrimSpace(value) + if strings.Contains(value, "://") || strings.IndexFunc(value, unicode.IsSpace) >= 0 { + return false + } + if strings.Count(value, ":") >= 2 { + // Require an IPv6-looking prefix. This still catches malformed values such + // as 2001:db8::zz without treating ordinary colon-delimited keywords as IPs. + parts := strings.Split(value, ":") + validSegments := 0 + for _, part := range parts { + if part == "" { + if validSegments > 0 || strings.HasPrefix(value, "::") { + return true + } + continue + } + if len(part) > 4 { + return false + } + for _, r := range part { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { + return false + } + } + validSegments++ + if validSegments >= 2 { + return true + } + } + return false + } + if !strings.Contains(value, ".") { + return false + } + for _, r := range value { + if r != '.' && (r < '0' || r > '9') { + return false + } + } + return true +} + // ParseScopeInput validates an explicitly typed rule. Legacy callers can omit -// Kind and retain the existing domain/IP/CIDR auto-detection behavior. +// Kind and use the same automatic classification as the single-textarea UI. func ParseScopeInput(input ScopeInput) (ParsedScope, error) { kind := strings.ToLower(strings.TrimSpace(input.Kind)) raw := strings.TrimSpace(input.Value) if kind == "" { - return ParseScopeLine(raw) + return ParseAutoScopeLine(raw) } switch kind { case "domain", "ip", "cidr": @@ -76,6 +121,93 @@ func ParseScopeInput(input ScopeInput) (ParsedScope, error) { } } +// ParseAutoScopeLine classifies one untyped textarea line. Network-looking and +// domain-looking values remain strict so malformed ranges do not silently become +// Agent keywords; all other non-empty text is a keyword. +func ParseAutoScopeLine(line string) (ParsedScope, error) { + raw := strings.TrimSpace(line) + if raw == "" { + return ParsedScope{}, fmt.Errorf("空行") + } + + if _, _, err := net.ParseCIDR(raw); err == nil { + return ParseScopeLine(raw) + } + if ip := net.ParseIP(raw); ip != nil { + return ParseScopeLine(raw) + } + if slash := strings.LastIndexByte(raw, '/'); slash > 0 { + address := strings.TrimSpace(raw[:slash]) + if net.ParseIP(address) != nil || looksLikeIPAddress(address) { + return ParsedScope{Raw: raw}, fmt.Errorf("无效 CIDR: %s", raw) + } + } + + if looksLikeIPAddress(raw) { + return ParsedScope{Raw: raw}, fmt.Errorf("无效 IP: %s", raw) + } + + looksLikeDomain := strings.Contains(raw, "://") || + (strings.Contains(raw, ".") && strings.IndexFunc(raw, unicode.IsSpace) < 0) + if looksLikeDomain { + return ParseScopeLine(raw) + } + lower := strings.ToLower(raw) + if strings.Contains(lower, "icp") || strings.Contains(raw, "备案") { + return ParseScopeInput(ScopeInput{Kind: "icp", Value: raw}) + } + return ParseScopeInput(ScopeInput{Kind: "keyword", Value: raw}) +} + +func scopeHostname(raw string) (string, error) { + candidate := strings.TrimSpace(raw) + if candidate == "" { + return "", fmt.Errorf("主机名为空") + } + if strings.HasPrefix(candidate, "//") { + candidate = "http:" + candidate + } else if !strings.Contains(candidate, "://") { + candidate = "http://" + candidate + } + parsed, err := url.Parse(candidate) + if err != nil || parsed.Host == "" { + if err == nil { + err = fmt.Errorf("缺少主机名") + } + return "", err + } + host := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".") + if host == "" { + return "", fmt.Errorf("主机名为空") + } + if ip := net.ParseIP(host); ip != nil { + return ip.String(), nil + } + host, err = idna.Lookup.ToASCII(host) + if err != nil { + return "", err + } + host = strings.ToLower(host) + if len(host) > 253 { + return "", fmt.Errorf("域名超过 253 个字符") + } + labels := strings.Split(host, ".") + if len(labels) < 2 { + return "", fmt.Errorf("域名至少需要两个标签") + } + for _, label := range labels { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return "", fmt.Errorf("域名标签无效") + } + for _, r := range label { + if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') { + return "", fmt.Errorf("域名包含无效字符") + } + } + } + return host, nil +} + // ParseScopeLine classifies and validates one scope line (root domain / IP / // CIDR). Guardrails reject bare TLDs and over-broad networks so a rule can never // swallow the internet. IP ranges must be expressed as CIDR. @@ -85,12 +217,8 @@ func ParseScopeLine(line string) (ParsedScope, error) { if raw == "" { return r, fmt.Errorf("空行") } - s := raw - if i := strings.Index(s, "://"); i >= 0 { // strip scheme if a URL was pasted - s = s[i+3:] - } - // CIDR first — it contains '/', which the path-strip below would remove. - if _, ipnet, err := net.ParseCIDR(s); err == nil { + // CIDR first because URL parsing treats its slash as a path separator. + if _, ipnet, err := net.ParseCIDR(raw); err == nil { ones, bits := ipnet.Mask.Size() if bits == 32 && ones < 16 { return r, fmt.Errorf("网段过宽(IPv4 需 >= /16): %s", raw) @@ -101,12 +229,8 @@ func ParseScopeLine(line string) (ParsedScope, error) { r.Kind, r.Net = "cidr", ipnet.String() return r, nil } - // Strip any path and :port remnants for host-like inputs. - if i := strings.IndexByte(s, '/'); i >= 0 { - s = s[:i] - } // Single IP. - if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil { + if ip := net.ParseIP(raw); ip != nil { r.Kind = "ip" if ip.To4() != nil { r.Net = ip.String() + "/32" @@ -115,37 +239,30 @@ func ParseScopeLine(line string) (ParsedScope, error) { } return r, nil } - if host, _, ok := strings.Cut(s, ":"); ok { // host:port → host - s = host + host, err := scopeHostname(raw) + if err != nil { + return r, fmt.Errorf("无法识别为有效域名/IP/CIDR: %s", raw) + } + if ip := net.ParseIP(host); ip != nil { + r.Kind = "ip" + if ip.To4() != nil { + r.Net = ip.String() + "/32" + } else { + r.Net = ip.String() + "/128" + } + return r, nil + } + if looksLikeIPAddress(host) { + return r, fmt.Errorf("无效 IP: %s", raw) } if strings.Contains(raw, "-") && strings.Count(raw, ".") >= 6 { return r, fmt.Errorf("IP 段请用 CIDR 表示(如 1.2.3.0/24): %s", raw) } // Domain (registrable). Reject bare TLDs / public suffixes. - d := DomainKey(s) - if d == "" || !strings.Contains(d, ".") { - return r, fmt.Errorf("无法识别为根域名/IP/CIDR: %s", raw) - } + d := DomainKey(host) if suf, icann := publicsuffix.PublicSuffix(d); icann && suf == d { return r, fmt.Errorf("不能用裸 TLD 作为范围: %s", raw) } r.Kind, r.Domain = "domain", d return r, nil } - -// ParseScopeLines parses a whole text block (one entry per line), returning the -// valid rules and the invalid lines (with reasons) so the caller can report both. -func ParseScopeLines(text string) (rules []ParsedScope, invalid []string) { - for _, ln := range strings.Split(text, "\n") { - if strings.TrimSpace(ln) == "" { - continue - } - r, err := ParseScopeLine(ln) - if err != nil { - invalid = append(invalid, err.Error()) - continue - } - rules = append(rules, r) - } - return rules, invalid -} diff --git a/db/company_scope_test.go b/db/company_scope_test.go index 2139e7d..f1be837 100644 --- a/db/company_scope_test.go +++ b/db/company_scope_test.go @@ -40,6 +40,65 @@ func TestParseScopeLine(t *testing.T) { } } +func TestParseAutoScopeLine(t *testing.T) { + cases := []struct { + name string + input string + kind string + normalized string + wantErr bool + }{ + {name: "domain", input: "example.com", kind: "domain", normalized: "example.com"}, + {name: "url", input: "https://sub.example.com/path", kind: "domain", normalized: "sub.example.com"}, + {name: "url query", input: "https://example.com/path?source=x", kind: "domain", normalized: "example.com"}, + {name: "url credentials", input: "https://user:pass@example.com/path", kind: "domain", normalized: "example.com"}, + {name: "url ip", input: "http://203.0.113.10/path", kind: "ip", normalized: "203.0.113.10/32"}, + {name: "ipv4", input: "203.0.113.10", kind: "ip", normalized: "203.0.113.10/32"}, + {name: "ipv6", input: "2001:db8::10", kind: "ip", normalized: "2001:db8::10/128"}, + {name: "cidr", input: "198.51.100.0/24", kind: "cidr", normalized: "198.51.100.0/24"}, + {name: "icp latin", input: "京 ICP备 123号", kind: "icp", normalized: "京icp备123号"}, + {name: "icp chinese", input: "沪网备案 9988", kind: "icp", normalized: "沪网备案9988"}, + {name: "icp domain", input: "icp.example.com", kind: "domain", normalized: "icp.example.com"}, + {name: "icp url query", input: "https://example.com/path?icp=1", kind: "domain", normalized: "example.com"}, + {name: "keyword", input: " ACME Security ", kind: "keyword", normalized: "acme security"}, + {name: "colon keyword", input: "ACME: Cloud: Security", kind: "keyword", normalized: "acme: cloud: security"}, + {name: "empty", input: " ", wantErr: true}, + {name: "invalid cidr", input: "10.0.0.0/not-a-prefix", wantErr: true}, + {name: "invalid ipv4", input: "999.0.0.1", wantErr: true}, + {name: "invalid ipv6", input: "2001:db8::zz", wantErr: true}, + {name: "invalid ipv6 cidr", input: "2001:db8::zz/64", wantErr: true}, + {name: "overbroad cidr", input: "10.0.0.0/8", wantErr: true}, + {name: "bare suffix", input: "co.uk", wantErr: true}, + {name: "empty domain label", input: "foo..example.com", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rule, err := ParseAutoScopeLine(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("ParseAutoScopeLine(%q) = %+v, want error", tc.input, rule) + } + return + } + if err != nil { + t.Fatalf("ParseAutoScopeLine(%q): %v", tc.input, err) + } + if rule.Kind != tc.kind { + t.Fatalf("kind=%q want %q", rule.Kind, tc.kind) + } + got := rule.Value + if rule.Kind == "domain" { + got = rule.Domain + } else if rule.Kind == "ip" || rule.Kind == "cidr" { + got = rule.Net + } + if got != tc.normalized { + t.Fatalf("normalized=%q want %q", got, tc.normalized) + } + }) + } +} + func TestExplicitCompanyAttributionSurvivesScopeRebuild(t *testing.T) { d, as, cs := testSetup(t) defer d.Close() @@ -277,13 +336,6 @@ func TestCompanyScopeAttribution(t *testing.T) { t.Fatalf("pre-scope asset should be unattributed, got %v", *preCompanyID) } - rules, invalid := ParseScopeLines(root + "\n198.51.100.0/24\nco.uk") - if len(rules) != 2 { - t.Fatalf("want 2 valid rules, got %d (%+v)", len(rules), rules) - } - if len(invalid) != 1 { - t.Fatalf("want 1 invalid line, got %v", invalid) - } cs.AddScope(cid, []string{root, "198.51.100.0/24"}, "unit test") mustCid := func(id int64, want int64, label string) { diff --git a/server/server.go b/server/server.go index 15fd08f..272379d 100644 --- a/server/server.go +++ b/server/server.go @@ -667,7 +667,6 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles) mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler) mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent) - mux.HandleFunc("POST /api/tasks/{id}/intents/control/batch", s.controlIntentsBatch) mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图 mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图 mux.HandleFunc("POST /api/active", s.setActive) diff --git a/server/task_admission_test.go b/server/task_admission_test.go index eff7446..8c9c695 100644 --- a/server/task_admission_test.go +++ b/server/task_admission_test.go @@ -688,43 +688,6 @@ func TestBatchControlLimitCountsDeduplicatedIDs(t *testing.T) { t.Fatalf("unique task ids status=%d body=%s", rec.Code, rec.Body.String()) } - task, err := m.CreateTask("batch intent limit", "deduplicate intent ids", nil, 0, 0) - if err != nil { - t.Fatal(err) - } - defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }() - repeatedIntents := make([]string, maxBatchControlIDs+1) - for i := range repeatedIntents { - repeatedIntents[i] = "999999" - } - rec = request(s.controlIntentsBatch, "/api/tasks/1/intents/control/batch", map[string]string{"id": task.ID}, map[string]any{ - "intent_ids": repeatedIntents, - "action": "pause", - }) - if rec.Code != http.StatusOK { - t.Fatalf("deduplicated intent ids status=%d body=%s", rec.Code, rec.Body.String()) - } - var intentPayload struct { - Items []batchControlItem `json:"items"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &intentPayload); err != nil { - t.Fatal(err) - } - if len(intentPayload.Items) != 1 || intentPayload.Items[0].ID != "999999" { - t.Fatalf("deduplicated intent response=%+v", intentPayload.Items) - } - - uniqueIntents := make([]string, maxBatchControlIDs+1) - for i := range uniqueIntents { - uniqueIntents[i] = strconv.Itoa(i + 1) - } - rec = request(s.controlIntentsBatch, "/api/tasks/1/intents/control/batch", map[string]string{"id": task.ID}, map[string]any{ - "intent_ids": uniqueIntents, - "action": "resume", - }) - if rec.Code != http.StatusBadRequest { - t.Fatalf("unique intent ids status=%d body=%s", rec.Code, rec.Body.String()) - } } func TestPauseTaskToolPersistsAndDequeuesTask(t *testing.T) { diff --git a/server/task_control.go b/server/task_control.go index 3811233..5e5f9ca 100644 --- a/server/task_control.go +++ b/server/task_control.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "net/http" - "strconv" "strings" "github.com/Autumn-27/artex/agent" @@ -32,7 +31,6 @@ type batchControlItem struct { ID string `json:"id"` OK bool `json:"ok"` Status string `json:"status,omitempty"` - State string `json:"state,omitempty"` Queued bool `json:"queued,omitempty"` Error string `json:"error,omitempty"` } @@ -241,74 +239,3 @@ func (s *Server) controlTasksBatch(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, map[string]any{"items": items}) } - -func (s *Server) controlIntentsBatch(w http.ResponseWriter, r *http.Request) { - t, ok := s.m.Task(r.PathValue("id")) - if !ok { - writeErr(w, 404, "task not found") - return - } - r.Body = http.MaxBytesReader(w, r.Body, 256<<10) - var req struct { - IntentIDs []string `json:"intent_ids"` - Action string `json:"action"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErr(w, 400, "bad json: "+err.Error()) - return - } - if req.Action != "pause" && req.Action != "resume" { - writeErr(w, 400, "action must be pause|resume") - return - } - type parsedIntentID struct { - raw string - id int64 - valid bool - } - seen := map[int64]bool{} - seenInvalid := map[string]bool{} - intentIDs := make([]parsedIntentID, 0, len(req.IntentIDs)) - for _, raw := range req.IntentIDs { - iid, err := strconv.ParseInt(raw, 10, 64) - if err != nil || iid <= 0 { - if seenInvalid[raw] { - continue - } - seenInvalid[raw] = true - intentIDs = append(intentIDs, parsedIntentID{raw: raw}) - continue - } - if seen[iid] { - continue - } - seen[iid] = true - intentIDs = append(intentIDs, parsedIntentID{raw: raw, id: iid, valid: true}) - } - if len(intentIDs) == 0 || len(intentIDs) > maxBatchControlIDs { - writeErr(w, 400, fmt.Sprintf("intent_ids 数量必须为 1-%d", maxBatchControlIDs)) - return - } - if !s.engine.beginTaskOperation(t.ID) { - writeErr(w, 409, "任务正在删除,无法控制意图") - return - } - defer s.engine.decInflight(t.ID) - items := make([]batchControlItem, 0, len(intentIDs)) - for _, parsed := range intentIDs { - item := batchControlItem{ID: parsed.raw} - if !parsed.valid { - item.Error = "bad intent id" - items = append(items, item) - continue - } - result, err := s.applyIntentControl(r.Context(), t, parsed.id, req.Action) - if err != nil { - item.Error = err.Error() - } else { - item.OK, item.State = true, result.State - } - items = append(items, item) - } - writeJSON(w, 200, map[string]any{"items": items}) -} diff --git a/server/task_control_routes_test.go b/server/task_control_routes_test.go new file mode 100644 index 0000000..26e2ad6 --- /dev/null +++ b/server/task_control_routes_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWorkerControlRoutes(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer m.Close() + + td := t.TempDir() + s := New(context.Background(), m, td, td, td) + h := s.Handler() + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatalf("sign jwt: %v", err) + } + + request := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"action":"pause"}`)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + // The single-worker route is still registered and reaches its JSON handler. + single := request("/api/tasks/missing/intents/1/control") + if single.Code != http.StatusNotFound || !strings.Contains(single.Body.String(), `"error":"task not found"`) { + t.Fatalf("single control route unavailable: status=%d body=%s", single.Code, single.Body.String()) + } + + // The former batch route must fall through the mux instead of reaching a task handler. + batch := request("/api/tasks/missing/intents/control/batch") + if batch.Code != http.StatusNotFound || !strings.Contains(batch.Body.String(), "404 page not found") { + t.Fatalf("batch control route still registered: status=%d body=%s", batch.Code, batch.Body.String()) + } +} diff --git a/traffic/traffic.go b/traffic/traffic.go index 49675d2..bf8f241 100644 --- a/traffic/traffic.go +++ b/traffic/traffic.go @@ -219,7 +219,7 @@ func (t *Traffic) record(f *mproxy.Flow) { respBody := t.bodyOrBlob(f.Response.Body) ct := f.Response.Header.Get("Content-Type") - reqTxt := fmt.Sprintf("%s %s %s\n%s\n%s", method, f.Request.URL.RequestURI(), f.Request.Proto, headerLines(f.Request.Header), reqBody) + reqTxt := fmt.Sprintf("%s %s %s\n%s\n%s", method, f.Request.URL.RequestURI(), f.Request.Proto, requestHeaderLines(f.Request), reqBody) respTxt := fmt.Sprintf("HTTP %d\n%s\n%s", f.Response.StatusCode, headerLines(f.Response.Header), respBody) _ = os.WriteFile(filepath.Join(exDir, "request.http"), []byte(reqTxt), 0o644) _ = os.WriteFile(filepath.Join(exDir, "response.http"), []byte(respTxt), 0o644) @@ -267,6 +267,26 @@ func headerLines(h map[string][]string) string { return b.String() } +// requestHeaderLines restores the HTTP Host header, which net/http stores on +// Request.Host rather than in Header. Keeping it in request.http makes the raw +// capture complete and directly replayable. +func requestHeaderLines(req *mproxy.Request) string { + headers := req.Header.Clone() + headers.Del("Host") + host := req.URL.Host + if raw := req.Raw(); raw != nil && strings.TrimSpace(raw.Host) != "" { + host = raw.Host + } + var b strings.Builder + if host = strings.TrimSpace(host); host != "" { + b.WriteString("Host: ") + b.WriteString(host) + b.WriteByte('\n') + } + b.WriteString(headerLines(headers)) + return b.String() +} + func sanitize(s string) string { r := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "?", "_", "*", "_", "\"", "_", "<", "_", ">", "_", "|", "_") out := r.Replace(s) diff --git a/traffic/traffic_test.go b/traffic/traffic_test.go index 497bcf5..ea818b5 100644 --- a/traffic/traffic_test.go +++ b/traffic/traffic_test.go @@ -2,12 +2,30 @@ package traffic import ( "fmt" + "net/http" + "net/url" "os" "path/filepath" "strings" "testing" + + mproxy "github.com/lqqyt2423/go-mitmproxy/proxy" ) +func TestRequestHeaderLinesIncludesHost(t *testing.T) { + req := &mproxy.Request{ + URL: &url.URL{Host: "target.example:8443"}, + Header: http.Header{"Accept": []string{"application/json"}}, + } + got := requestHeaderLines(req) + if !strings.Contains(got, "Host: target.example:8443\n") { + t.Fatalf("request headers missing Host: %q", got) + } + if !strings.Contains(got, "Accept: application/json\n") { + t.Fatalf("request headers missing regular header: %q", got) + } +} + // TestDeleteHost verifies the delete contract: rows for hosts containing the // substring are removed together with their file trees, non-matching hosts are // untouched, and the count is right. diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index c320a19..9d822f6 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -35,7 +35,7 @@ export function MainContent({ children }: { children: ReactNode }) { useEffect(() => { api .health() - .then((h) => setVersion(h.version ?? "")) + .then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, ""))) .catch(() => setVersion("")); }, []); diff --git a/web/src/app/(main)/chat/page.tsx b/web/src/app/(main)/chat/page.tsx index d059be2..6c1dc66 100644 --- a/web/src/app/(main)/chat/page.tsx +++ b/web/src/app/(main)/chat/page.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { + ArrowUpIcon, Bot, ChevronDownIcon, Loader2Icon, @@ -12,7 +13,6 @@ import { PinIcon, PinOffIcon, PlusIcon, - SendIcon, Square, Trash2Icon, XIcon, @@ -202,8 +202,14 @@ function Composer({ ) : ( - )} diff --git a/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index 881c0b9..df1b5b3 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -10,7 +10,6 @@ import { BugIcon, ClockIcon, NetworkIcon, - PlusIcon, ShieldCheckIcon, TargetIcon, ZapIcon, @@ -177,7 +176,7 @@ export default function DashboardPage() { /* transient errors — next poll retries */ } }; - load(); + void load(); const t = setInterval(load, 5000); return () => { alive = false; @@ -213,7 +212,7 @@ export default function DashboardPage() { /* transient errors */ } }; - load(); + void load(); const t = setInterval(load, 15000); return () => { alive = false; @@ -461,17 +460,11 @@ export default function DashboardPage() { return (
{/* ── Header ── */} -
+

总览

系统全局状态 · 实时刷新

- - -
{/* ── Row 1: 5 stat cards ── */} @@ -575,6 +568,7 @@ export default function DashboardPage() { ] as const ).map(({ v, label }) => ( -
- {rule.value && error ? {error} : null} - - ); - })} - -
+ 0}> + 资产范围 + 每行一条,自动识别域名、IP、CIDR、ICP 备案和企业关键词。 +