diff --git a/examples/README.md b/examples/README.md index e8eb23d..562d662 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,6 +10,8 @@ examples/ ├── neutron/ # POC 扫描工具 ├── gogo/ # 端口扫描和指纹识别工具 ├── spray/ # HTTP 批量探测工具 +├── pending_pocs/ # 加载待审核 / 未启用的 POC +├── pending_fingerprints/ # 加载待审核 / 未启用的指纹 └── cases/ # 小颗粒度使用案例(cookbook) ├── match_detail/ # 获取 fingers matcher 详情(cmd + test) └── spray_crawl_finger/ # 单 URL 爬虫 + 深度指纹探测(cmd + test) @@ -292,6 +294,79 @@ go test ./cases/spray_crawl_finger -v 要点:`spray.NewConfig().WithMatchDetail()` 负责把 matcher 细节带进 `common.Framework`,随后在 `spray.Context` 上打开 `SetCrawlPlugin(true)` 和 `SetFinger(true)` 即可。 +### pending_pocs - 加载待审核 / 未启用的 POC + +默认 SDK 只导出 `status=active` 的 POC(向后兼容老用户)。如果客户端需要把 +**待审核(`pending`)**、**草稿(`draft`)**、**已禁用(`inactive`)** 等规则也一起拉下来, +通过 `cyberhub.NewExportFilter().WithStatuses(...)` 显式声明即可: + +```go +filter := cyberhub.NewExportFilter(). + WithStatuses("active", "pending", "draft") +config := neutron.NewConfig().WithCyberhub(url, key) +config.ExportFilter = filter +engine, _ := neutron.NewEngine(config) +``` + +如需按审核工单状态过滤(如只看正在待审核工单的规则),再加: + +```go +filter.WithReviewStatus("pending") +``` + +合法值: + +- `WithStatuses(...)`:`active` / `pending` / `draft` / `inactive` / `deprecated` +- `WithReviewStatus(...)`:`pending` / `approved` / `rejected` / `draft` / `none` + +完整示例: + +```bash +# 默认 active +go run ./pending_pocs -url http://127.0.0.1:8080 -key your_api_key + +# 加载 active + pending + draft +go run ./pending_pocs -url ... -key ... -statuses active,pending,draft + +# 只看正在待审核工单的规则 +go run ./pending_pocs -url ... -key ... -review pending +``` + +注意:未显式调用 `WithStatuses(...)` / `WithReviewStatus(...)` 的旧客户端不会受影响 —— +SDK 仍只导出 active 状态的 POC。 + +### pending_fingerprints - 加载待审核 / 未启用的指纹 + +和 POC 不同,**SDK 拉取指纹时不会强制 `status=active`**,后端默认就会返回 +`active + 非空 pending + inactive + deprecated`。但 `draft` 和"`raw_content` 为空的 pending" +仍会被后端 `shouldHideDraftOnlyFingerprints` 规则隐掉。如果需要拿到这部分"空壳" +待审核指纹,仍要显式声明: + +```go +filter := cyberhub.NewExportFilter(). + WithStatuses("active", "pending", "draft", "inactive") +config := fingers.NewConfig().WithCyberhub(url, key) +config.ExportFilter = filter +engine, _ := fingers.NewEngine(config) +``` + +CLI 演示: + +```bash +# 走后端默认(不含 draft 和空 pending) +go run ./pending_fingerprints -url http://127.0.0.1:8080 -key your_api_key + +# 显式拉全部非删除态(含 draft / 空 pending) +go run ./pending_fingerprints -url ... -key ... -statuses active,pending,draft,inactive + +# 只看正在待审核工单的指纹 +go run ./pending_fingerprints -url ... -key ... -review pending +``` + +> 提示:`ExportFilter` 是 POC 和指纹共用的,`WithStatuses(...)` / `WithReviewStatus(...)` +> 在 `fingers.Engine` 和 `neutron.Engine` 上的语义一致;只是默认行为不同 +> (POC 默认 active,指纹默认非 deleted)。 + --- ## 常见问题 diff --git a/examples/pending_fingerprints/main.go b/examples/pending_fingerprints/main.go new file mode 100644 index 0000000..b742d1c --- /dev/null +++ b/examples/pending_fingerprints/main.go @@ -0,0 +1,91 @@ +// Example: load 待审核 / 草稿 / 未启用 (non-active) fingerprints from a Cyberhub backend. +// +// 与 examples/pending_pocs 的区别: +// +// - POC 导出:SDK 默认强制 status=active;要拉 pending/draft,必须显式 WithStatuses(...)。 +// - 指纹导出:SDK 不强制状态,后端默认就会返回 active + 非空 pending + inactive + deprecated; +// 但 draft 和"raw_content 为空的 pending"会被后端 shouldHideDraftOnlyFingerprints +// 规则隐掉。如果客户端要拿到这部分"空壳待审核"指纹,仍需显式 +// WithStatuses("pending") / WithStatuses("draft") 等。 +// +// 用法: +// +// pending_fingerprints -url http://127.0.0.1:8080 -key YOUR_KEY +// pending_fingerprints -url ... -key ... -statuses active,pending,draft +// pending_fingerprints -url ... -key ... -review pending +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/chainreactors/sdk/fingers" + "github.com/chainreactors/sdk/pkg/cyberhub" +) + +var ( + cyberhubURL = flag.String("url", "", "Cyberhub URL (e.g., http://127.0.0.1:8080)") + apiKey = flag.String("key", "", "Cyberhub API Key") + statuses = flag.String("statuses", "", + "指纹生命周期状态(逗号分隔):active / pending / draft / inactive / deprecated;留空走后端默认(不含 draft 和空 pending)") + review = flag.String("review", "", + "审核流程状态:pending / approved / rejected / draft / none,留空表示不按审核状态过滤") + preview = flag.Int("preview", 10, "最多打印多少条指纹摘要") +) + +func main() { + flag.Parse() + + if *cyberhubURL == "" || *apiKey == "" { + fmt.Println("usage: pending_fingerprints -url -key [-statuses active,pending] [-review pending]") + flag.PrintDefaults() + os.Exit(1) + } + + // 1. 构造 ExportFilter;不调 WithStatuses 时走后端默认语义。 + filter := cyberhub.NewExportFilter() + if list := splitCSV(*statuses); len(list) > 0 { + filter.WithStatuses(list...) + } + if *review != "" { + filter.WithReviewStatus(*review) + } + + // 2. 挂到 fingers.Config 上(和 POC 路径完全对称) + config := fingers.NewConfig().WithCyberhub(*cyberhubURL, *apiKey) + config.ExportFilter = filter + + // 3. 创建引擎,触发拉取 + engine, err := fingers.NewEngine(config) + if err != nil { + fmt.Printf("create engine failed: %v\n", err) + os.Exit(1) + } + + fmt.Printf("加载到 %d 条指纹 (statuses=%q review=%q)\n", engine.Count(), *statuses, *review) + + items := config.FullFingers.Fingers() + limit := *preview + if limit > len(items) { + limit = len(items) + } + for i := 0; i < limit; i++ { + f := items[i] + fmt.Printf(" [%s] protocol=%s tags=%v\n", f.Name, f.Protocol, f.Tags) + } + if len(items) > limit { + fmt.Printf("... (省略 %d 条)\n", len(items)-limit) + } +} + +func splitCSV(s string) []string { + out := []string{} + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/examples/pending_pocs/main.go b/examples/pending_pocs/main.go new file mode 100644 index 0000000..32d779d --- /dev/null +++ b/examples/pending_pocs/main.go @@ -0,0 +1,85 @@ +// Example: load 待审核 / 未启用 (non-active) POCs from a Cyberhub backend. +// +// 默认情况下 SDK 只会拉取 status=active 的 POC(向后兼容老用户)。 +// 如需加载待审核 / 草稿 / 已禁用的规则,显式通过 +// cyberhub.NewExportFilter().WithStatuses(...) / .WithReviewStatus(...) 指定。 +// +// 用法: +// +// pending_pocs -url http://127.0.0.1:8080 -key YOUR_KEY +// pending_pocs -url ... -key ... -statuses pending,draft +// pending_pocs -url ... -key ... -review pending +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/chainreactors/sdk/neutron" + "github.com/chainreactors/sdk/pkg/cyberhub" +) + +var ( + cyberhubURL = flag.String("url", "", "Cyberhub URL (e.g., http://127.0.0.1:8080)") + apiKey = flag.String("key", "", "Cyberhub API Key") + statuses = flag.String("statuses", "active,pending,draft", + "POC 生命周期状态(逗号分隔):active / pending / draft / inactive / deprecated") + review = flag.String("review", "", + "审核流程状态:pending / approved / rejected / draft / none,留空表示不按审核状态过滤") + preview = flag.Int("preview", 10, "最多打印多少条 POC 摘要") +) + +func main() { + flag.Parse() + + if *cyberhubURL == "" || *apiKey == "" { + fmt.Println("usage: pending_pocs -url -key [-statuses active,pending] [-review pending]") + flag.PrintDefaults() + os.Exit(1) + } + + // 1. 构造 ExportFilter,显式声明需要的状态 + filter := cyberhub.NewExportFilter(). + WithStatuses(splitCSV(*statuses)...) + if *review != "" { + filter.WithReviewStatus(*review) + } + + // 2. 挂到 neutron.Config 上(和 examples/filter/main.go 同款用法) + config := neutron.NewConfig().WithCyberhub(*cyberhubURL, *apiKey) + config.ExportFilter = filter + + // 3. 创建引擎,触发拉取 + engine, err := neutron.NewEngine(config) + if err != nil { + fmt.Printf("create engine failed: %v\n", err) + os.Exit(1) + } + + tpls := engine.Get() + fmt.Printf("加载到 %d 条 POC (statuses=%s review=%q)\n", len(tpls), *statuses, *review) + + limit := *preview + if limit > len(tpls) { + limit = len(tpls) + } + for i := 0; i < limit; i++ { + t := tpls[i] + fmt.Printf(" [%s] %s severity=%s\n", t.Id, t.Info.Name, t.Info.Severity) + } + if len(tpls) > limit { + fmt.Printf("... (省略 %d 条)\n", len(tpls)-limit) + } +} + +func splitCSV(s string) []string { + out := []string{} + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/pkg/cyberhub/client.go b/pkg/cyberhub/client.go index 8ff0359..d55a0e6 100644 --- a/pkg/cyberhub/client.go +++ b/pkg/cyberhub/client.go @@ -123,12 +123,12 @@ func (c *Client) ExportPOCs(ctx context.Context, tags []string, severities []str params.Add("sources", source) } - // 只导出激活状态的 POC - params.Set("status", "active") - - // 添加筛选参数 + // 添加筛选参数(包括 Statuses / ReviewStatus,调用方未显式指定时下方再回退 active) applyFilterParams(params, firstFilter(filters)) + // 向后兼容:调用方未显式指定 POC 状态时,默认仅导出 active + applyDefaultPOCStatus(params) + endpoint := fmt.Sprintf("%s/pocs/export?%s", c.baseURL, params.Encode()) var response POCListResponse @@ -141,6 +141,11 @@ func (c *Client) ExportPOCs(ctx context.Context, tags []string, severities []str // ExportPOCsByNames 按名称列表导出 POC func (c *Client) ExportPOCsByNames(ctx context.Context, names []string) ([]POCResponse, error) { + return c.ExportPOCsByNamesWithFilter(ctx, names, nil) +} + +// ExportPOCsByNamesWithFilter 按名称列表导出 POC,并应用额外筛选条件。 +func (c *Client) ExportPOCsByNamesWithFilter(ctx context.Context, names []string, filter *ExportFilter) ([]POCResponse, error) { if len(names) == 0 { return nil, nil } @@ -149,7 +154,12 @@ func (c *Client) ExportPOCsByNames(ctx context.Context, names []string) ([]POCRe for _, name := range names { params.Add("names", name) } - params.Set("status", "active") + + // 添加筛选参数;调用方未显式指定状态时下方再回退 active,保持旧调用行为。 + applyFilterParams(params, filter) + + // 按名称导出沿用默认行为:仅导出 active 状态。 + applyDefaultPOCStatus(params) endpoint := fmt.Sprintf("%s/pocs/export?%s", c.baseURL, params.Encode()) @@ -237,6 +247,50 @@ func applyFilterParams(params url.Values, filter *ExportFilter) { params.Set("page", "1") params.Set("page_size", strconv.Itoa(filter.Limit)) } + + // 生命周期状态:透传为 statuses=...(多值),后端走 IN(...) 分支。 + // 用 dedup 逻辑避免重复透传。 + if len(filter.Statuses) > 0 { + existingStatuses := make(map[string]struct{}) + for _, s := range params["statuses"] { + if s == "" { + continue + } + existingStatuses[s] = struct{}{} + } + for _, s := range filter.Statuses { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if _, exists := existingStatuses[s]; exists { + continue + } + params.Add("statuses", s) + existingStatuses[s] = struct{}{} + } + } + + // 审核流程状态:单值,存在即覆盖。 + if rs := strings.TrimSpace(filter.ReviewStatus); rs != "" { + params.Set("review_status", rs) + } +} + +// applyDefaultPOCStatus 在调用方未显式指定任何 POC 状态相关参数时, +// 把请求收敛回"仅导出 active",保持与旧版 SDK 一致的行为。 +// 显式 statuses= / status= / review_status= 任一存在时,不再注入默认值。 +func applyDefaultPOCStatus(params url.Values) { + if len(params["statuses"]) > 0 { + return + } + if params.Get("status") != "" { + return + } + if params.Get("review_status") != "" { + return + } + params.Set("status", "active") } type requestBodyProvider struct { diff --git a/pkg/cyberhub/client_test.go b/pkg/cyberhub/client_test.go index 43c15bc..8f9c488 100644 --- a/pkg/cyberhub/client_test.go +++ b/pkg/cyberhub/client_test.go @@ -1,8 +1,14 @@ package cyberhub import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" "net/url" + "sort" "testing" + "time" ) func TestFirstFilter(t *testing.T) { @@ -75,3 +81,199 @@ func TestApplyFilterParams_LimitOnly(t *testing.T) { t.Fatalf("expected page_size=10, got %q", params.Get("page_size")) } } + +func TestApplyFilterParams_Statuses(t *testing.T) { + params := url.Values{} + params.Add("statuses", "active") + + filter := &ExportFilter{ + Statuses: []string{"active", "pending", "draft", ""}, + } + + applyFilterParams(params, filter) + + got := params["statuses"] + sort.Strings(got) + want := []string{"active", "draft", "pending"} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected %v, got %v", want, got) + } + } +} + +func TestApplyFilterParams_ReviewStatus(t *testing.T) { + params := url.Values{} + filter := &ExportFilter{ + ReviewStatus: " pending ", + } + + applyFilterParams(params, filter) + + if got := params.Get("review_status"); got != "pending" { + t.Fatalf("expected review_status=pending, got %q", got) + } +} + +func TestApplyDefaultPOCStatus(t *testing.T) { + cases := []struct { + name string + setup func(url.Values) + wantStatus string + wantSkip bool + }{ + { + name: "no filter falls back to active", + setup: func(p url.Values) {}, + wantStatus: "active", + }, + { + name: "explicit single status preserved", + setup: func(p url.Values) { + p.Set("status", "pending") + }, + wantStatus: "pending", + }, + { + name: "explicit multi statuses suppresses default", + setup: func(p url.Values) { + p.Add("statuses", "active") + p.Add("statuses", "pending") + }, + wantSkip: true, + }, + { + name: "review_status alone suppresses default", + setup: func(p url.Values) { + p.Set("review_status", "pending") + }, + wantSkip: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + params := url.Values{} + tc.setup(params) + applyDefaultPOCStatus(params) + + if tc.wantSkip { + if got := params.Get("status"); got != "" { + t.Fatalf("expected status= unset, got %q", got) + } + return + } + if got := params.Get("status"); got != tc.wantStatus { + t.Fatalf("expected status=%q, got %q", tc.wantStatus, got) + } + }) + } +} + +// TestExportPOCs_StatusBehavior verifies the end-to-end request shape against a mock +// Cyberhub backend: default (active only), explicit Statuses (overrides default), +// and ReviewStatus (suppresses default). +func TestExportPOCs_StatusBehavior(t *testing.T) { + var captured url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.URL.Query() + resp := APIResponse{ + Code: 0, + Message: "ok", + Data: POCListResponse{ + POCs: []POCResponse{}, + Total: 0, + Exported: 0, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", 5*time.Second) + ctx := context.Background() + + t.Run("default exports active only", func(t *testing.T) { + captured = nil + if _, err := client.ExportPOCs(ctx, nil, nil, "", "", nil); err != nil { + t.Fatalf("ExportPOCs failed: %v", err) + } + if got := captured.Get("status"); got != "active" { + t.Fatalf("expected default status=active, got %q", got) + } + if got := captured["statuses"]; len(got) != 0 { + t.Fatalf("expected no statuses= param, got %v", got) + } + }) + + t.Run("explicit Statuses overrides default", func(t *testing.T) { + captured = nil + filter := NewExportFilter().WithStatuses("active", "pending", "draft") + if _, err := client.ExportPOCs(ctx, nil, nil, "", "", filter); err != nil { + t.Fatalf("ExportPOCs failed: %v", err) + } + if got := captured.Get("status"); got != "" { + t.Fatalf("expected status= empty, got %q", got) + } + got := captured["statuses"] + sort.Strings(got) + want := []string{"active", "draft", "pending"} + if len(got) != len(want) { + t.Fatalf("expected statuses=%v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected statuses=%v, got %v", want, got) + } + } + }) + + t.Run("ReviewStatus suppresses default active", func(t *testing.T) { + captured = nil + filter := NewExportFilter().WithReviewStatus("pending") + if _, err := client.ExportPOCs(ctx, nil, nil, "", "", filter); err != nil { + t.Fatalf("ExportPOCs failed: %v", err) + } + if got := captured.Get("status"); got != "" { + t.Fatalf("expected status= empty (so review-pending POCs not filtered out), got %q", got) + } + if got := captured.Get("review_status"); got != "pending" { + t.Fatalf("expected review_status=pending, got %q", got) + } + }) + + t.Run("names default exports active only", func(t *testing.T) { + captured = nil + if _, err := client.ExportPOCsByNames(ctx, []string{"example-poc"}); err != nil { + t.Fatalf("ExportPOCsByNames failed: %v", err) + } + if got := captured.Get("status"); got != "active" { + t.Fatalf("expected default status=active, got %q", got) + } + if got := captured["statuses"]; len(got) != 0 { + t.Fatalf("expected no statuses= param, got %v", got) + } + if got := captured["names"]; len(got) != 1 || got[0] != "example-poc" { + t.Fatalf("expected names=[example-poc], got %v", got) + } + }) + + t.Run("names explicit Statuses overrides default", func(t *testing.T) { + captured = nil + filter := NewExportFilter().WithStatuses("pending") + if _, err := client.ExportPOCsByNamesWithFilter(ctx, []string{"pending-poc"}, filter); err != nil { + t.Fatalf("ExportPOCsByNamesWithFilter failed: %v", err) + } + if got := captured.Get("status"); got != "" { + t.Fatalf("expected status= empty, got %q", got) + } + if got := captured["statuses"]; len(got) != 1 || got[0] != "pending" { + t.Fatalf("expected statuses=[pending], got %v", got) + } + }) +} diff --git a/pkg/cyberhub/types.go b/pkg/cyberhub/types.go index c0f7ac1..42892cd 100644 --- a/pkg/cyberhub/types.go +++ b/pkg/cyberhub/types.go @@ -20,6 +20,18 @@ type ExportFilter struct { // 来源筛选(多个来源为 OR 关系) Sources []string + // 生命周期状态筛选(多个状态为 OR 关系,POC / 指纹导出均会透传) + // POC 留空时 SDK 默认仅导出 active 状态,保持向后兼容; + // 指纹留空时走后端默认语义。 + // POC 显式指定后默认 active 行为被覆盖,可用于加载待审核 / 草稿 / 未启用规则。 + // 合法值:active / pending / draft / inactive / deprecated。 + Statuses []string + + // 审核流程状态筛选(POC / 指纹导出均会透传)。 + // 对 POC 而言,显式指定后默认 active 行为被覆盖。 + // 留空表示不按审核状态过滤;合法值:pending / approved / rejected / draft / none。 + ReviewStatus string + // 时间范围筛选 CreatedAfter *time.Time // 创建时间起始 CreatedBefore *time.Time // 创建时间截止 @@ -47,6 +59,22 @@ func (f *ExportFilter) WithSources(sources ...string) *ExportFilter { return f } +// WithStatuses 设置生命周期状态筛选(POC / 指纹导出均会透传)。 +// POC 调用此方法将覆盖 SDK 默认仅导出 active 的行为,可用于加载待审核 / 草稿 / 未启用规则。 +// 合法值:active / pending / draft / inactive / deprecated。 +func (f *ExportFilter) WithStatuses(statuses ...string) *ExportFilter { + f.Statuses = statuses + return f +} + +// WithReviewStatus 设置审核流程状态筛选(POC / 指纹导出均会透传)。 +// POC 调用此方法将覆盖 SDK 默认仅导出 active 的行为。 +// 留空表示不过滤;合法值:pending / approved / rejected / draft / none。 +func (f *ExportFilter) WithReviewStatus(status string) *ExportFilter { + f.ReviewStatus = status + return f +} + // WithCreatedAfter 设置创建时间起始 func (f *ExportFilter) WithCreatedAfter(t time.Time) *ExportFilter { f.CreatedAfter = &t @@ -130,9 +158,14 @@ func (r *FingerprintResponse) GetAlias() *alias.Alias { return r.Alias } -// IsActive 检查是否为激活状态(Export API 只返回 active 状态的指纹) +// 注:Cyberhub 后端的 Fingerprint.Status(active/pending/draft/inactive 等) +// 不在 export payload 里序列化,因此 FingerprintResponse 无法仅凭返回数据判断 +// "是否激活"。如需按状态筛选,使用 ExportFilter.WithStatuses(...) 在请求侧过滤。 +// +// Deprecated: ExportFingerprints 的响应体不包含 Fingerprint.Status,返回值只能表示旧版 +// SDK 的历史假设,不能用于判断显式状态筛选后的真实生命周期状态。 func (r *FingerprintResponse) IsActive() bool { - return true // Export API 默认只导出 active 状态 + return true } // GetTemplate 获取 Template 对象(直接返回嵌入的 Template)