Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pkg/cyberhub/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
61 changes: 61 additions & 0 deletions pkg/cyberhub/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"net/url"
"testing"
"time"

"github.com/chainreactors/sdk/pkg/types"
)

func TestApplyFilterParams_DedupTags(t *testing.T) {
Expand Down Expand Up @@ -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}
Expand Down
55 changes: 55 additions & 0 deletions pkg/cyberhub/export.go
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 10 additions & 10 deletions pkg/types/export_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down