Skip to content

Commit 89d1a00

Browse files
committed
x-pack/filebeat/input/streaming: add configurable User-Agent header
Add a user_agent config field and a transport-level decorator that sets the User-Agent header on all outbound HTTP requests made by the CrowdStrike streaming input (discover, firehose, session refresh, and OAuth2 token fetch). The decorator unconditionally overwrites Go's default "Go-http-client/1.1" which http.Client.Do sets before calling RoundTrip. When user_agent is empty, the Elastic Agent's built-in user agent string is used as the default. This is needed for CrowdStrike technology partner compliance, which requires a User-Agent identifying the integration and version on every API request.
1 parent 352bd9f commit 89d1a00

4 files changed

Lines changed: 155 additions & 6 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: enhancement
2+
summary: Add configurable User-Agent header to the CrowdStrike streaming input.
3+
component: filebeat

x-pack/filebeat/input/streaming/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ type config struct {
6363
// domain as the configured URL. Entries here extend the set
6464
// of accepted origins.
6565
ResourceOrigins []string `config:"resource_origins"`
66+
// UserAgent overrides the default User-Agent header sent on
67+
// all outbound HTTP requests (discover, firehose, session
68+
// refresh, and OAuth2 token fetch). When empty, the Elastic
69+
// Agent's built-in user agent string is used.
70+
UserAgent string `config:"user_agent"`
6671
}
6772

6873
type redact struct {

x-pack/filebeat/input/streaming/crowdstrike.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ func NewFalconHoseFollower(ctx context.Context, env v2.Context, cfg config, curs
197197
s.allowedOrigins = append(s.allowedOrigins, o)
198198
}
199199

200+
ua := cfg.UserAgent
201+
if ua == "" {
202+
ua = env.Agent.UserAgent
203+
}
204+
200205
// Build the auth transport before zeroing timeouts for the streaming
201206
// client. The oauth2 token endpoint needs normal request timeouts;
202207
// moving this after the timeout zeroing will cause auth requests to
@@ -210,7 +215,7 @@ func NewFalconHoseFollower(ctx context.Context, env v2.Context, cfg config, curs
210215
now = time.Now
211216
}
212217
s.authTransport = &rateLimitTransport{
213-
base: authClient.Transport,
218+
base: userAgentTransport{ua: ua, base: authClient.Transport},
214219
timeout: authClient.Timeout,
215220
maxRetry: 3,
216221
wait: 60 * time.Second,
@@ -225,10 +230,27 @@ func NewFalconHoseFollower(ctx context.Context, env v2.Context, cfg config, curs
225230
stat.UpdateStatus(status.Failed, "failed to configure client: "+err.Error())
226231
return nil, err
227232
}
233+
s.plainClient.Transport = userAgentTransport{ua: ua, base: s.plainClient.Transport}
228234

229235
return &s, nil
230236
}
231237

238+
var _ http.RoundTripper = userAgentTransport{}
239+
240+
// userAgentTransport wraps an http.RoundTripper and unconditionally
241+
// sets the User-Agent header on every outgoing request. It overwrites
242+
// Go's default "Go-http-client/1.1" which http.Client.Do sets before
243+
// calling RoundTrip.
244+
type userAgentTransport struct {
245+
ua string
246+
base http.RoundTripper
247+
}
248+
249+
func (t userAgentTransport) RoundTrip(r *http.Request) (*http.Response, error) {
250+
r.Header.Set("User-Agent", t.ua)
251+
return t.base.RoundTrip(r)
252+
}
253+
232254
// FollowStream receives, processes and publishes events from the subscribed
233255
// FalconHose stream.
234256
func (s *falconHoseStream) FollowStream(ctx context.Context) error {

x-pack/filebeat/input/streaming/crowdstrike_unit_test.go

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,18 @@ import (
1212
"net/http"
1313
"net/http/httptest"
1414
"strings"
15+
"sync"
1516
"testing"
1617
"time"
1718

1819
"github.com/elastic/beats/v7/libbeat/beat"
19-
"github.com/elastic/elastic-agent-libs/logp"
2020
"github.com/elastic/elastic-agent-libs/logp/logptest"
2121
"github.com/elastic/elastic-agent-libs/monitoring"
2222

2323
cursor "github.com/elastic/beats/v7/filebeat/input/v2/input-cursor"
2424
)
2525

2626
func TestFollowSession_FirehoseHTTPError(t *testing.T) {
27-
logp.TestingSetup()
28-
2927
tests := []struct {
3028
name string
3129
statusCode int
@@ -118,8 +116,6 @@ func TestFollowSession_DiscoverGETFailureIsTransient(t *testing.T) {
118116
}
119117

120118
func TestFollowSession_NonObjectMessage(t *testing.T) {
121-
logp.TestingSetup()
122-
123119
validEvent := `{"metadata":{"eventType":"TestEvent","offset":1},"event":{"TestField":"value"}}`
124120

125121
tests := []struct {
@@ -176,6 +172,129 @@ func TestFollowSession_NonObjectMessage(t *testing.T) {
176172
}
177173
}
178174

175+
func TestUserAgentTransport(t *testing.T) {
176+
const want = "Elastic-crowdstrike/4.0.0"
177+
178+
var got string
179+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180+
got = r.Header.Get("User-Agent")
181+
}))
182+
defer srv.Close()
183+
184+
cli := srv.Client()
185+
cli.Transport = userAgentTransport{ua: want, base: cli.Transport}
186+
187+
// http.Client.Do sets "Go-http-client/1.1" before RoundTrip;
188+
// the transport must overwrite it.
189+
req, err := http.NewRequest("GET", srv.URL, nil)
190+
if err != nil {
191+
t.Fatal(err)
192+
}
193+
resp, err := cli.Do(req)
194+
if err != nil {
195+
t.Fatal(err)
196+
}
197+
resp.Body.Close()
198+
199+
if got != want {
200+
t.Errorf("User-Agent = %q; want %q", got, want)
201+
}
202+
}
203+
204+
func TestFollowSession_UserAgent(t *testing.T) {
205+
const want = "Elastic-crowdstrike/4.0.0"
206+
207+
type requestRecord struct {
208+
path string
209+
userAgent string
210+
}
211+
var (
212+
mu sync.Mutex
213+
requests []requestRecord
214+
)
215+
record := func(r *http.Request) {
216+
mu.Lock()
217+
requests = append(requests, requestRecord{path: r.URL.Path, userAgent: r.Header.Get("User-Agent")})
218+
mu.Unlock()
219+
}
220+
221+
firehoseSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222+
record(r)
223+
switch {
224+
case strings.HasPrefix(r.URL.Path, "/firehose"):
225+
// Send one event then EOF to end the session cleanly.
226+
fmt.Fprintln(w, `{"metadata":{"eventType":"Test","offset":1},"event":{"field":"value"}}`)
227+
case strings.HasPrefix(r.URL.Path, "/refresh"):
228+
w.WriteHeader(http.StatusOK)
229+
}
230+
}))
231+
defer firehoseSrv.Close()
232+
233+
discoverSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234+
record(r)
235+
w.Header().Set("Content-Type", "application/json")
236+
// refreshActiveSessionInterval is short but refreshSessionWait
237+
// clamps to a 15s floor, so no refresh fires during the test.
238+
resp := map[string]any{
239+
"resources": []map[string]any{
240+
{
241+
"dataFeedURL": firehoseSrv.URL + "/firehose",
242+
"sessionToken": map[string]any{
243+
"token": "test-token",
244+
"expiration": "2099-01-01T00:00:00Z",
245+
},
246+
"refreshActiveSessionURL": firehoseSrv.URL + "/refresh",
247+
"refreshActiveSessionInterval": 1,
248+
},
249+
},
250+
"meta": map[string]any{},
251+
}
252+
b, _ := json.Marshal(resp)
253+
w.Write(b)
254+
}))
255+
defer discoverSrv.Close()
256+
257+
// Build clients wrapped with the userAgentTransport, matching the
258+
// production wiring in NewFalconHoseFollower.
259+
authClient := discoverSrv.Client()
260+
authClient.Transport = userAgentTransport{ua: want, base: authClient.Transport}
261+
plainClient := firehoseSrv.Client()
262+
plainClient.Transport = userAgentTransport{ua: want, base: plainClient.Transport}
263+
264+
pub := new(countingPublisher)
265+
s := newTestStreamWithPublisher(t, discoverSrv.URL, plainClient, pub)
266+
267+
state := map[string]any{}
268+
_, err := s.followSession(context.Background(), authClient, state)
269+
if err != nil {
270+
t.Fatalf("followSession() unexpected error: %v", err)
271+
}
272+
273+
// Check that the discover and firehose requests carried the custom
274+
// User-Agent. We don't assert on refresh because the goroutine may
275+
// or may not fire within the test window.
276+
mu.Lock()
277+
defer mu.Unlock()
278+
var sawDiscover, sawFirehose bool
279+
for _, r := range requests {
280+
if r.userAgent != want {
281+
t.Errorf("request to %s had User-Agent = %q; want %q", r.path, r.userAgent, want)
282+
}
283+
switch {
284+
case r.path == "/" || r.path == "":
285+
sawDiscover = true
286+
case strings.HasPrefix(r.path, "/firehose"):
287+
sawFirehose = true
288+
}
289+
}
290+
if !sawDiscover {
291+
t.Error("no discover request recorded")
292+
}
293+
if !sawFirehose {
294+
t.Error("no firehose request recorded")
295+
}
296+
}
297+
179298
func discoverResponse(t *testing.T, feedURL, refreshURL string) string {
180299
t.Helper()
181300
resp := map[string]any{

0 commit comments

Comments
 (0)