diff --git a/pkg/cyberhub/client.go b/pkg/cyberhub/client.go index abc2d50..1447c4f 100644 --- a/pkg/cyberhub/client.go +++ b/pkg/cyberhub/client.go @@ -115,9 +115,9 @@ func applyFilterParams(params url.Values, filter *ExportFilter) { } // Draft is orthogonal to Statuses / ReviewStatus: filter fields choose - // the rows, with_draft chooses the column read off each row. Only emit - // the query param when the caller explicitly asked for drafts so the - // default backend semantics (RawContent) is preserved. + // the rows, with_draft asks the backend to include the pending draft + // column alongside the approved/effective raw_content. Only emit the + // query param when the caller explicitly asked for drafts. if filter.Draft { params.Set("with_draft", "true") } diff --git a/pkg/cyberhub/client_test.go b/pkg/cyberhub/client_test.go index d661bbf..a896b4b 100644 --- a/pkg/cyberhub/client_test.go +++ b/pkg/cyberhub/client_test.go @@ -8,6 +8,8 @@ import ( "net/url" "testing" "time" + + "github.com/chainreactors/sdk/pkg/types" ) func TestApplyFilterParams_DedupTags(t *testing.T) { @@ -175,6 +177,65 @@ func TestWithDraftBuilder(t *testing.T) { } } +func TestProviderExportFingersReturnsRawContentFields(t *testing.T) { + var captured url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.URL.Query() + data := fingerprintExportListResponse{ + Fingerprints: []FingerprintExport{ + { + Finger: &types.Finger{Name: "pending-hub", Protocol: "http"}, + Engine: "fingerprinthub", + Source: "unit-source", + SourceNames: []string{"unit-source"}, + RawContent: "approved-yaml", + RawContentDraft: "pending-yaml", + }, + }, + } + resp := apiResponse{Code: 0, Message: "ok", Data: data} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + filter := NewExportFilter().WithReviewStatus("pending").WithDraft(true) + p := NewProvider(server.URL, "test-key").WithFilter(filter).WithTimeout(5 * time.Second) + records, err := p.ExportFingers(context.Background()) + if err != nil { + t.Fatalf("ExportFingers failed: %v", err) + } + + if got := captured.Get("with_draft"); got != "true" { + t.Fatalf("expected with_draft=true, got %q", got) + } + if got := captured.Get("review_status"); got != "pending" { + t.Fatalf("expected review_status=pending, got %q", got) + } + if len(records) != 1 { + t.Fatalf("expected one record, got %d", len(records)) + } + record := records[0] + if record.Finger == nil || record.Finger.Name != "pending-hub" { + t.Fatalf("expected embedded finger pending-hub, got %#v", record.Finger) + } + if record.Engine != "fingerprinthub" { + t.Fatalf("expected engine fingerprinthub, got %q", record.Engine) + } + if record.RawContent != "approved-yaml" { + t.Fatalf("expected approved raw_content, got %q", record.RawContent) + } + if record.RawContentDraft != "pending-yaml" { + t.Fatalf("expected pending raw_content_draft, got %q", record.RawContentDraft) + } + if got := record.EffectiveRawContent(); got != "pending-yaml" { + t.Fatalf("expected effective raw content to prefer draft, got %q", got) + } + if got := (FingerprintExport{RawContent: "approved-yaml"}).EffectiveRawContent(); got != "approved-yaml" { + t.Fatalf("expected effective raw content to fall back to approved, got %q", got) + } +} + func TestApplyFilterParams_Limit(t *testing.T) { params := url.Values{} filter := &ExportFilter{Limit: 10} diff --git a/pkg/cyberhub/export.go b/pkg/cyberhub/export.go new file mode 100644 index 0000000..e76c8a5 --- /dev/null +++ b/pkg/cyberhub/export.go @@ -0,0 +1,55 @@ +package cyberhub + +import ( + "context" + "fmt" + "net/url" + + "github.com/chainreactors/sdk/pkg/types" +) + +// FingerprintExport is one raw fingerprint export row returned by CyberHub. +// Finger is the parsed rule used by the scan engine; RawContent is the +// approved/effective YAML and RawContentDraft is the pending draft YAML when +// requested with ExportFilter.WithDraft(true). +type FingerprintExport struct { + *types.Finger `json:",inline" yaml:",inline"` + Alias *types.Alias `json:"alias,omitempty" yaml:"alias,omitempty"` + Engine string `json:"engine,omitempty" yaml:"engine,omitempty"` + Source string `json:"source,omitempty" yaml:"source,omitempty"` + SourceNames []string `json:"source_names,omitempty" yaml:"source_names,omitempty"` + RawContent string `json:"raw_content,omitempty" yaml:"raw_content,omitempty"` + RawContentDraft string `json:"raw_content_draft,omitempty" yaml:"raw_content_draft,omitempty"` +} + +// EffectiveRawContent returns the draft YAML when present, otherwise the +// approved/effective YAML. +func (f FingerprintExport) EffectiveRawContent() string { + if f.RawContentDraft != "" { + return f.RawContentDraft + } + return f.RawContent +} + +type fingerprintExportListResponse struct { + Fingerprints []FingerprintExport `json:"fingerprints"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ExportFingers exports full fingerprint records, including raw_content and +// raw_content_draft fields. +func (p *Provider) ExportFingers(ctx context.Context) ([]FingerprintExport, error) { + params := url.Values{} + params.Set("with_fingerprint", "true") + applyFilterParams(params, p.filter) + + endpoint := fmt.Sprintf("%s/fingerprints/export?%s", p.client().baseURL, params.Encode()) + + var response fingerprintExportListResponse + if err := p.client().doRequest(ctx, "GET", endpoint, nil, &response); err != nil { + return nil, fmt.Errorf("export fingers failed: %w", err) + } + return response.Fingerprints, nil +} diff --git a/pkg/types/export_filter.go b/pkg/types/export_filter.go index c01fc42..ed669b5 100644 --- a/pkg/types/export_filter.go +++ b/pkg/types/export_filter.go @@ -12,13 +12,13 @@ type ExportFilter struct { Statuses []string ReviewStatus string - // Draft controls whether unreviewed-draft content is returned in place of - // the approved RawContent. When true the backend sends back RawContentDraft - // for rows that have one (and the approved RawContent otherwise), which is - // the only way to actually read brand-new pending entries or pending edits. + // Draft controls whether the backend also returns pending draft content. + // For fingerprint exports, RawContent remains the approved/effective + // content and RawContentDraft is returned alongside it when available. + // For POC exports, the backend parses the draft template when available. // // Draft is orthogonal to Statuses / ReviewStatus: filter fields decide - // which rows are returned, Draft decides which column is read. + // which rows are returned, Draft decides whether draft content is included. Draft bool CreatedAfter *time.Time @@ -93,11 +93,11 @@ func (f *ExportFilter) WithReviewStatus(status string) *ExportFilter { return f } -// WithDraft toggles draft mode: when true the backend returns RawContentDraft -// for rows that have a pending-review draft (and falls back to RawContent -// otherwise). Combine with WithStatuses("pending"|"draft") or -// WithReviewStatus("pending") to actually pull pending rows — the filter -// fields decide which rows come back, WithDraft decides which column is read. +// WithDraft toggles draft mode. For fingerprint exports, the backend keeps +// raw_content as the approved/effective content and adds raw_content_draft +// for rows that have a pending-review draft. Combine with +// WithStatuses("pending"|"draft") or WithReviewStatus("pending") to actually +// pull pending-only rows; the filter fields decide which rows come back. func (f *ExportFilter) WithDraft(draft bool) *ExportFilter { f.Draft = draft return f