From 4715958311174cdbc3878cd9de68c8d9a319fc2d Mon Sep 17 00:00:00 2001 From: Ujjwal Kumar Date: Tue, 2 Dec 2025 14:54:36 +0530 Subject: [PATCH] feat: added support for har encoding for the http requests Signed-off-by: Ujjwal Kumar --- .secrets.baseline | 51 +++- core/base_service.go | 62 ++++- core/har_encoder.go | 571 +++++++++++++++++++++++++++++++++++++++ core/har_encoder_test.go | 424 +++++++++++++++++++++++++++++ 4 files changed, 1100 insertions(+), 8 deletions(-) create mode 100644 core/har_encoder.go create mode 100644 core/har_encoder_test.go diff --git a/.secrets.baseline b/.secrets.baseline index 846720ab..7bcbae72 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "go.sum|package-lock.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2025-06-10T15:52:17Z", + "generated_at": "2025-11-11T08:34:03Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -148,7 +148,7 @@ "hashed_secret": "bc2f74c22f98f7b6ffbc2f67453dbfa99bce9a32", "is_secret": false, "is_verified": false, - "line_number": 928, + "line_number": 942, "type": "Secret Keyword", "verified_result": null } @@ -430,7 +430,7 @@ "hashed_secret": "84ed7427f222c7a1f43567e1bb3058365a81bbcb", "is_secret": false, "is_verified": false, - "line_number": 307, + "line_number": 308, "type": "Secret Keyword", "verified_result": null }, @@ -438,7 +438,7 @@ "hashed_secret": "d4a9d12d425a0edaf333f49c6004b6d417eeb87b", "is_secret": false, "is_verified": false, - "line_number": 308, + "line_number": 309, "type": "Secret Keyword", "verified_result": null } @@ -527,6 +527,43 @@ "verified_result": null } ], + "core/har_encoder_test.go": [ + { + "hashed_secret": "9ee139bb3ce0806ade2cde711cad3dfe835d5ee8", + "is_verified": false, + "line_number": 60, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "274756dc517fb81dc5586b77c109f9c750754cdc", + "is_verified": false, + "line_number": 65, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "eab6dd1ed6bcd3e21dd1bb3925f8f15528875891", + "is_verified": false, + "line_number": 66, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "93568f5f9035a6863f4c25f6ce26ab4dcaf1ee3c", + "is_verified": false, + "line_number": 93, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "edc01c0cd48b0a72718ccddf844aa1e2dcda8cbb", + "is_verified": false, + "line_number": 170, + "type": "Secret Keyword", + "verified_result": null + } + ], "core/iam_assume_authenticator.go": [ { "hashed_secret": "d7c931824fedea3f78d340f1b8fda515c70feb7a", @@ -790,7 +827,7 @@ "hashed_secret": "347cd9c53ff77d41a7b22aa56c7b4efaf54658e3", "is_secret": false, "is_verified": false, - "line_number": 300, + "line_number": 301, "type": "Secret Keyword", "verified_result": null } @@ -858,7 +895,7 @@ "hashed_secret": "e19c2ac701d392407b565d6ac8078b81c83e605c", "is_secret": false, "is_verified": false, - "line_number": 471, + "line_number": 472, "type": "Secret Keyword", "verified_result": null } @@ -1100,7 +1137,7 @@ } ] }, - "version": "0.13.1+ibm.62.dss", + "version": "0.13.1+ibm.64.dss", "word_list": { "file": null, "hash": null diff --git a/core/base_service.go b/core/base_service.go index 55854945..41c6a2ea 100644 --- a/core/base_service.go +++ b/core/base_service.go @@ -357,6 +357,37 @@ func (service *BaseService) SetUserAgent(userAgent string) { // // err: a non-nil error object if an error occurred func (service *BaseService) Request(req *http.Request, result interface{}) (detailedResponse *DetailedResponse, err error) { + // HAR capture state (no-op unless HAR_ENABLED=1). + var httpResponse *http.Response + var harStart, harEnd time.Time + var harReqBodyCopy, harRespBodyCopy bytes.Buffer + var harReqContentType, harRespContentType string + var harCallErr error + + if HAREnabled() { + harStart = time.Now() + harReqContentType = req.Header.Get(CONTENT_TYPE) + if req.Body != nil && req.Body != http.NoBody { + req.Body = io.NopCloser(io.TeeReader(req.Body, &harReqBodyCopy)) + } + } + defer func() { + if HAREnabled() { + harCallErr = err + HARAppendWithCopies( + req, + httpResponse, + harStart, + harEnd, + harCallErr, + harReqBodyCopy.Bytes(), + harRespBodyCopy.Bytes(), + harReqContentType, + harRespContentType, + ) + } + }() + // Set default headers on the request. if service.DefaultHeaders != nil { for k, v := range service.DefaultHeaders { @@ -418,8 +449,15 @@ func (service *BaseService) Request(req *http.Request, result interface{}) (deta // Invoke the request, then check for errors during the invocation. GetLogger().Debug("Sending HTTP request message...") - var httpResponse *http.Response httpResponse, err = service.Client.Do(req) + + if HAREnabled() { + harEnd = time.Now() + if httpResponse != nil { + harRespContentType = httpResponse.Header.Get(CONTENT_TYPE) + } + } + if err != nil { if strings.Contains(err.Error(), SSL_CERTIFICATION_ERROR) { err = errors.New(ERRORMSG_SSL_VERIFICATION_FAILED + "\n" + err.Error()) @@ -439,6 +477,14 @@ func (service *BaseService) Request(req *http.Request, result interface{}) (deta } } + // Wrap response body for HAR capture. + if HAREnabled() && httpResponse != nil && httpResponse.Body != nil && httpResponse.Body != http.NoBody { + httpResponse.Body = &harBodyCapture{ + ReadCloser: httpResponse.Body, + capture: &harRespBodyCopy, + } + } + // If the operation was unsuccessful, then set up and return // the DetailedResponse and error objects appropriately. if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 { @@ -956,3 +1002,17 @@ func IBMCloudSDKBackoffPolicy(min, max time.Duration, attemptNum int, resp *http // to compute an exponential backoff. return retryablehttp.DefaultBackoff(min, max, attemptNum, resp) } + +// harBodyCapture wraps an io.ReadCloser to capture body content for HAR while allowing normal reading. +type harBodyCapture struct { + io.ReadCloser + capture *bytes.Buffer +} + +func (h *harBodyCapture) Read(p []byte) (n int, err error) { + n, err = h.ReadCloser.Read(p) + if n > 0 && h.capture != nil { + _, _ = h.capture.Write(p[:n]) + } + return n, err +} diff --git a/core/har_encoder.go b/core/har_encoder.go new file mode 100644 index 00000000..ec371e34 --- /dev/null +++ b/core/har_encoder.go @@ -0,0 +1,571 @@ +//go:build !js + +package core + +// (C) Copyright IBM Corp. 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +const ( + harMaxEntries = 10000 // Limit entries to prevent unbounded growth + harBinaryThreshold = 0.05 // Ratio of non-printable chars for binary detection +) + +var ( + harOnce sync.Once + harEnabled bool + harFilePath string + harMutex sync.Mutex + + // Regex patterns for redacting secrets in non-JSON content. + bearerTokenPattern = regexp.MustCompile(`(?i)(bearer\s+)([a-zA-Z0-9\-._~+/]+=*)`) + basicAuthPattern = regexp.MustCompile(`(?i)(basic\s+)([a-zA-Z0-9+/]+=*)`) + apiKeyPattern = regexp.MustCompile(`(?i)(apikey[\s:=]+)([a-zA-Z0-9\-._~+/]+)`) + tokenPattern = regexp.MustCompile(`(?i)(token[\s:=]+)([a-zA-Z0-9\-._~+/]+)`) + iamTokenPattern = regexp.MustCompile(`(?i)(iam[_-]?token[\s:=]+)([a-zA-Z0-9\-._~+/]+)`) + accessTokenPattern = regexp.MustCompile(`(?i)(access[_-]?token[\s:=]+)([a-zA-Z0-9\-._~+/]+)`) + sessionTokenPattern = regexp.MustCompile(`(?i)(session[_-]?token[\s:=]+)([a-zA-Z0-9\-._~+/]+)`) + passwordPattern = regexp.MustCompile(`(?i)(password[\s:=]+)([^\s&"'<>]+)`) + secretPattern = regexp.MustCompile(`(?i)(secret[\s:=]+)([a-zA-Z0-9\-._~+/]+)`) + cookiePattern = regexp.MustCompile(`(?i)(=[^;,\s]{8,})(;|,|$)`) +) + +// HAREnabled returns true if HAR recording is enabled via the HAR_ENABLED environment variable. +func HAREnabled() bool { + harOnce.Do(func() { + harEnabled = os.Getenv("HAR_ENABLED") == "1" + if harEnabled { + customPath := os.Getenv("HAR_FILE_PATH") + if customPath != "" { + harFilePath = customPath + } else { + harFilePath = filepath.Join(os.TempDir(), "ibm-go-sdk-core.har") + } + GetLogger().Info("HAR recording enabled, writing to: %s\n", harFilePath) + } + }) + return harEnabled +} + +// HAR 1.2 specification structures. +type harNameValue struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type harPostData struct { + MimeType string `json:"mimeType"` + Text string `json:"text,omitempty"` + Params []struct { + Name string `json:"name"` + Value string `json:"value,omitempty"` + } `json:"params,omitempty"` +} + +type harRequest struct { + Method string `json:"method"` + URL string `json:"url"` + HTTPVersion string `json:"httpVersion"` + Headers []harNameValue `json:"headers"` + QueryString []harNameValue `json:"queryString"` + PostData *harPostData `json:"postData,omitempty"` + HeadersSize int64 `json:"headersSize"` + BodySize int64 `json:"bodySize"` +} + +type harContent struct { + Size int64 `json:"size"` + MimeType string `json:"mimeType"` + Text string `json:"text,omitempty"` + Encoding string `json:"encoding,omitempty"` +} + +type harResponse struct { + Status int `json:"status"` + StatusText string `json:"statusText"` + HTTPVersion string `json:"httpVersion"` + Headers []harNameValue `json:"headers"` + Content harContent `json:"content"` + RedirectURL string `json:"redirectURL"` + HeadersSize int64 `json:"headersSize"` + BodySize int64 `json:"bodySize"` +} + +type harTimings struct { + Send float64 `json:"send"` + Wait float64 `json:"wait"` + Receive float64 `json:"receive"` +} + +type harEntry struct { + Pageref string `json:"pageref"` + StartedDateTime time.Time `json:"startedDateTime"` + Time float64 `json:"time"` + Request harRequest `json:"request"` + Response harResponse `json:"response"` + Cache struct{} `json:"cache"` + Timings harTimings `json:"timings"` + ServerIPAddress string `json:"serverIPAddress,omitempty"` + Connection string `json:"connection,omitempty"` +} + +type harLog struct { + Version string `json:"version"` + Creator struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"creator"` + Pages []struct{} `json:"pages"` + Entries []harEntry `json:"entries"` +} + +type harArchive struct { + Log harLog `json:"log"` +} + +// HARAppendWithCopies appends a request/response pair to the HAR file. +func HARAppendWithCopies( + req *http.Request, + resp *http.Response, + startTime, endTime time.Time, + callErr error, + reqBody, respBody []byte, + reqContentType, respContentType string, +) { + if !HAREnabled() || req == nil { + return + } + + entry := buildHAREntry(req, resp, startTime, endTime, callErr, reqBody, respBody, reqContentType, respContentType) + + harMutex.Lock() + defer harMutex.Unlock() + + archive := readOrCreateHARArchive() + + if len(archive.Log.Entries) >= harMaxEntries { + GetLogger().Warn("HAR file reached maximum entries (%d), rotating...\n", harMaxEntries) + rotateHARFile() + archive = createNewHARArchive() + } + + archive.Log.Entries = append(archive.Log.Entries, entry) + writeHARArchive(archive) +} + +func buildHAREntry( + req *http.Request, + resp *http.Response, + startTime, endTime time.Time, + callErr error, + reqBody, respBody []byte, + reqContentType, respContentType string, +) harEntry { + entry := harEntry{ + Pageref: "page_1", + StartedDateTime: startTime.UTC(), + Time: float64(endTime.Sub(startTime).Milliseconds()), + Request: buildHARRequest(req, reqBody, reqContentType), + Response: buildHARResponse(resp, callErr, respBody, respContentType, req), + Cache: struct{}{}, + Timings: harTimings{ + Send: -1, + Wait: float64(endTime.Sub(startTime).Milliseconds()), + Receive: -1, + }, + } + return entry +} + +func buildHARRequest(req *http.Request, reqBody []byte, contentType string) harRequest { + harReq := harRequest{ + Method: req.Method, + URL: req.URL.String(), + HTTPVersion: getHTTPVersion(req.Proto), + Headers: convertHeaders(req.Header, true), + QueryString: convertQueryString(req.URL), + HeadersSize: -1, + BodySize: int64(len(reqBody)), + } + + if len(reqBody) > 0 { + text, encoding := processBodyContent(reqBody, true, contentType) + if text != "" || encoding != "" { + harReq.PostData = &harPostData{ + MimeType: contentType, + Text: text, + } + } + } + + return harReq +} + +func buildHARResponse(resp *http.Response, callErr error, respBody []byte, contentType string, req *http.Request) harResponse { + harResp := harResponse{ + Status: getStatusCode(resp, callErr), + StatusText: getStatusText(resp, callErr), + HTTPVersion: getHTTPVersionFromResponse(resp, req), + Headers: convertHeaders(getResponseHeaders(resp), false), + HeadersSize: -1, + BodySize: int64(len(respBody)), + RedirectURL: "", + } + + text, encoding := processBodyContent(respBody, false, contentType) + harResp.Content = harContent{ + Size: int64(len(respBody)), + MimeType: contentType, + Text: text, + Encoding: encoding, + } + + if resp != nil && resp.StatusCode >= 300 && resp.StatusCode < 400 { + harResp.RedirectURL = resp.Header.Get("Location") + } + + return harResp +} + +func convertHeaders(headers http.Header, isRequest bool) []harNameValue { + if headers == nil { + return []harNameValue{} + } + var result []harNameValue + for name, values := range headers { + for _, value := range values { + if isSensitiveHeader(name) { + value = redactSecretValue(value) + } + result = append(result, harNameValue{ + Name: name, + Value: value, + }) + } + } + return result +} + +func convertQueryString(u *url.URL) []harNameValue { + if u == nil { + return []harNameValue{} + } + + var result []harNameValue + for name, values := range u.Query() { + for _, value := range values { + result = append(result, harNameValue{ + Name: name, + Value: value, + }) + } + } + return result +} + +func processBodyContent(body []byte, isRequest bool, contentType string) (text string, encoding string) { + if len(body) == 0 { + return "", "" + } + + if isBinaryContent(body) { + return base64.StdEncoding.EncodeToString(body), "base64" + } + + text = string(body) + + if strings.Contains(strings.ToLower(contentType), "json") || + strings.HasPrefix(strings.TrimSpace(text), "{") || + strings.HasPrefix(strings.TrimSpace(text), "[") { + redactedJSON := redactJSONSecrets(text) + if redactedJSON != "" { + return redactedJSON, "" + } + } + + text = redactSecretValue(text) + text = RedactSecrets(text) + + return text, "" +} + +func redactJSONSecrets(jsonStr string) string { + var data interface{} + + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + return "" + } + + redacted := redactJSONValue(data) + + result, err := json.MarshalIndent(redacted, "", " ") + if err != nil { + return "" + } + + return string(result) +} + +func redactJSONValue(val interface{}) interface{} { + switch v := val.(type) { + case map[string]interface{}: + result := make(map[string]interface{}) + for key, value := range v { + lowerKey := strings.ToLower(key) + if isSensitiveJSONKey(lowerKey) { + result[key] = getRedactionLabel(lowerKey) + } else { + result[key] = redactJSONValue(value) + } + } + return result + + case []interface{}: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = redactJSONValue(item) + } + return result + + case string: + if looksLikeToken(v) { + return "[REDACTED_TOKEN]" + } + return v + + default: + return v + } +} + +func isSensitiveJSONKey(key string) bool { + sensitiveKeys := []string{ + "token", "apikey", "api_key", "password", "secret", + "authorization", "auth", "credential", "access_token", + "refresh_token", "session_token", "bearer", "api-key", + "iam_token", "session_id", "cookie", "sessionid", + } + for _, sensitive := range sensitiveKeys { + if strings.Contains(key, sensitive) { + return true + } + } + return false +} + +func getRedactionLabel(key string) string { + if strings.Contains(key, "bearer") { + return "[REDACTED_BEARER_TOKEN]" + } + if strings.Contains(key, "apikey") || strings.Contains(key, "api_key") || strings.Contains(key, "api-key") { + return "[REDACTED_API_KEY]" + } + if strings.Contains(key, "password") { + return "[REDACTED_PASSWORD]" + } + if strings.Contains(key, "secret") { + return "[REDACTED_SECRET]" + } + if strings.Contains(key, "iam_token") || strings.Contains(key, "iam-token") { + return "[REDACTED_IAM_TOKEN]" + } + if strings.Contains(key, "access_token") || strings.Contains(key, "access-token") { + return "[REDACTED_ACCESS_TOKEN]" + } + if strings.Contains(key, "session") { + return "[REDACTED_SESSION_TOKEN]" + } + if strings.Contains(key, "cookie") { + return "[REDACTED_COOKIE]" + } + return "[REDACTED_TOKEN]" +} + +func looksLikeToken(s string) bool { + if len(s) > 32 && regexp.MustCompile(`^[A-Za-z0-9\-._~+/]+=*$`).MatchString(s) { + return true + } + if strings.Count(s, ".") == 2 && len(s) > 50 { + return true + } + return false +} + +func redactSecretValue(value string) string { + value = bearerTokenPattern.ReplaceAllString(value, "${1}[REDACTED_BEARER_TOKEN]") + value = basicAuthPattern.ReplaceAllString(value, "${1}[REDACTED_BASIC_AUTH]") + value = apiKeyPattern.ReplaceAllString(value, "${1}[REDACTED_API_KEY]") + value = iamTokenPattern.ReplaceAllString(value, "${1}[REDACTED_IAM_TOKEN]") + value = accessTokenPattern.ReplaceAllString(value, "${1}[REDACTED_ACCESS_TOKEN]") + value = sessionTokenPattern.ReplaceAllString(value, "${1}[REDACTED_SESSION_TOKEN]") + value = tokenPattern.ReplaceAllString(value, "${1}[REDACTED_TOKEN]") + value = passwordPattern.ReplaceAllString(value, "${1}[REDACTED_PASSWORD]") + value = secretPattern.ReplaceAllString(value, "${1}[REDACTED_SECRET]") + value = cookiePattern.ReplaceAllString(value, "=[REDACTED_COOKIE]${2}") + return value +} + +func isBinaryContent(data []byte) bool { + if len(data) == 0 { + return false + } + + nonPrintable := 0 + sampleSize := len(data) + if sampleSize > 8192 { + sampleSize = 8192 + } + + for i := 0; i < sampleSize; i++ { + b := data[i] + if b == 9 || b == 10 || b == 13 { + continue + } + if b < 32 || b > 126 { + nonPrintable++ + } + } + + ratio := float64(nonPrintable) / float64(sampleSize) + return ratio > harBinaryThreshold +} + +func isSensitiveHeader(name string) bool { + lowerName := strings.ToLower(name) + sensitivePatterns := []string{ + "authorization", + "cookie", + "set-cookie", + "token", + "apikey", + "api-key", + "secret", + "password", + "credential", + "session", + "x-auth", + "x-api", + } + + for _, pattern := range sensitivePatterns { + if strings.Contains(lowerName, pattern) { + return true + } + } + return false +} + +func getHTTPVersion(proto string) string { + if proto == "" { + return "HTTP/1.1" + } + return proto +} + +func getHTTPVersionFromResponse(resp *http.Response, req *http.Request) string { + if resp != nil && resp.Proto != "" { + return resp.Proto + } + if req != nil && req.Proto != "" { + return req.Proto + } + return "HTTP/1.1" +} + +func getStatusCode(resp *http.Response, err error) int { + if resp != nil { + return resp.StatusCode + } + if err != nil { + return 0 + } + return -1 +} + +func getStatusText(resp *http.Response, err error) string { + if resp != nil { + return resp.Status + } + if err != nil { + return err.Error() + } + return "" +} + +func getResponseHeaders(resp *http.Response) http.Header { + if resp == nil { + return nil + } + return resp.Header +} + +func readOrCreateHARArchive() harArchive { + data, err := os.ReadFile(harFilePath) + if err != nil || len(data) == 0 { + return createNewHARArchive() + } + + var archive harArchive + if err := json.Unmarshal(data, &archive); err != nil { + GetLogger().Warn("Failed to parse existing HAR file, creating new: %s\n", err.Error()) + return createNewHARArchive() + } + + return archive +} + +func createNewHARArchive() harArchive { + archive := harArchive{} + archive.Log.Version = "1.2" + archive.Log.Creator.Name = "ibm-go-sdk-core" + archive.Log.Creator.Version = __VERSION__ + archive.Log.Pages = []struct{}{} + archive.Log.Entries = []harEntry{} + return archive +} + +func writeHARArchive(archive harArchive) { + data, err := json.MarshalIndent(archive, "", " ") + if err != nil { + GetLogger().Error("Failed to marshal HAR archive: %s\n", err.Error()) + return + } + + if err := os.WriteFile(harFilePath, data, 0600); err != nil { + GetLogger().Error("Failed to write HAR file: %s\n", err.Error()) + } +} + +func rotateHARFile() { + timestamp := time.Now().Format("20060102-150405") + backupPath := strings.TrimSuffix(harFilePath, ".har") + "_" + timestamp + ".har" + + if err := os.Rename(harFilePath, backupPath); err != nil { + GetLogger().Error("Failed to rotate HAR file: %s\n", err.Error()) + } else { + GetLogger().Info("Rotated HAR file to: %s\n", backupPath) + } +} diff --git a/core/har_encoder_test.go b/core/har_encoder_test.go new file mode 100644 index 00000000..82e1cd4d --- /dev/null +++ b/core/har_encoder_test.go @@ -0,0 +1,424 @@ +// (C) Copyright IBM Corp. 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "os" + "strings" + "sync" + "testing" + "time" +) + +const ( + testBearerToken = "Bearer fake-test-token-xxx" // pragma: allowlist secret + testBasicAuth = "Basic dGVzdDp0ZXN0" // pragma: allowlist secret + testAPIKey = "apikey: test-api-key-placeholder" //nolint:gosec // pragma: allowlist secret + testGenericToken = "token: test-token-val" // pragma: allowlist secret + testIAMToken = "iam_token: test-iam-val" // pragma: allowlist secret + testAccessToken = "access_token: test-access-val" // pragma: allowlist secret + testSessionToken = "session-token: test-session-val" // pragma: allowlist secret + testPassword = "password: test-pass-placeholder" // pragma: allowlist secret + testSecret = "secret: test-secret-placeholder" // pragma: allowlist secret + testCookie = "Cookie: session=long-cookie-value-here-for-testing" // pragma: allowlist secret +) + +func TestHAREnabled(t *testing.T) { + origEnabled := os.Getenv("HAR_ENABLED") + origPath := os.Getenv("HAR_FILE_PATH") + defer os.Setenv("HAR_ENABLED", origEnabled) + defer os.Setenv("HAR_FILE_PATH", origPath) + + os.Setenv("HAR_ENABLED", "0") + os.Setenv("HAR_FILE_PATH", "") + harOnce = sync.Once{} + if HAREnabled() { + t.Error("expected HAR disabled when HAR_ENABLED=0") + } + + os.Setenv("HAR_ENABLED", "1") + harOnce = sync.Once{} + if !HAREnabled() { + t.Error("expected HAR enabled when HAR_ENABLED=1") + } +} + +func TestRedactSecretValue(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Bearer Token", testBearerToken, "[REDACTED_BEARER_TOKEN]"}, + {"Basic Auth", testBasicAuth, "[REDACTED_BASIC_AUTH]"}, + {"API Key", testAPIKey, "[REDACTED_API_KEY]"}, + {"Generic Token", testGenericToken, "[REDACTED_TOKEN]"}, + {"IAM Token", testIAMToken, "[REDACTED_IAM_TOKEN]"}, + {"Access Token", testAccessToken, "[REDACTED_ACCESS_TOKEN]"}, + {"Session Token", testSessionToken, "[REDACTED_SESSION_TOKEN]"}, + {"Password", testPassword, "[REDACTED_PASSWORD]"}, + {"Secret", testSecret, "[REDACTED_SECRET]"}, + {"Cookie", testCookie, "[REDACTED_COOKIE]"}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result := redactSecretValue(tc.input) + if !strings.Contains(result, tc.expected) { + t.Fatalf("expected redaction marker %q in %q", tc.expected, result) + } + var originalSecret string + for _, sep := range []string{": ", "=", ":"} { + parts := strings.SplitN(tc.input, sep, 2) + if len(parts) == 2 { + originalSecret = strings.TrimSpace(parts[1]) + break + } + } + if originalSecret != "" && len(originalSecret) > 5 && strings.Contains(result, originalSecret) { + t.Fatalf("original secret %q still visible in %q", originalSecret, result) + } + }) + } +} + +func TestRedactJSONSecretsViaProcessBodyContent(t *testing.T) { + body := []byte(`{"apikey":"test-api-key-placeholder","password":"p","nested":{"token":"zzz"}}`) // pragma: allowlist secret + + text, enc := processBodyContent(body, true, "application/json") + if enc != "" { + t.Fatalf("expected no encoding for JSON, got %q", enc) + } + if strings.Contains(text, "test-key-val") || strings.Contains(text, `"password":"p"`) || strings.Contains(text, `"token":"zzz"`) { + t.Fatalf("JSON secrets not redacted in %q", text) + } +} + +func TestConvertHeaders(t *testing.T) { + headers := http.Header{ + "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer fake-test-token-for-testing"}, // pragma: allowlist secret + "User-Agent": []string{"TestAgent/1.0"}, + "X-API-Key": []string{"test-api-key-val"}, // pragma: allowlist secret + } + + result := convertHeaders(headers, true) + + if len(result) != 4 { + t.Errorf("expected 4 headers, got %d", len(result)) + } + + for _, nv := range result { + switch nv.Name { + case "Authorization": + if !strings.Contains(nv.Value, "[REDACTED_") { + t.Errorf("Authorization header not redacted: %s", nv.Value) + } + if strings.Contains(nv.Value, "fake-test-token") { + t.Error("Original token still visible in Authorization header") + } + case "X-API-Key": + if nv.Value != "test-api-key-val" { + t.Errorf("X-API-Key unexpectedly altered: %q", nv.Value) + } + case "Content-Type": + if nv.Value != "application/json" { + t.Error("Non-sensitive header should not be redacted") + } + } + } +} + +func TestIsSensitiveHeader(t *testing.T) { + tests := []struct { + name string + isSensitive bool + }{ + {"Authorization", true}, + {"Cookie", true}, + {"Set-Cookie", true}, + {"X-Auth-Token", true}, + {"X-API-Key", true}, + {"Api-Key", true}, + {"Session-Token", true}, + {"X-Secret", true}, + {"Password", true}, + {"Content-Type", false}, + {"User-Agent", false}, + {"Accept", false}, + {"Host", false}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := isSensitiveHeader(tc.name) + if got != tc.isSensitive { + t.Fatalf("isSensitiveHeader(%q)=%v, want %v", tc.name, got, tc.isSensitive) + } + }) + } +} + +func TestProcessBodyContent(t *testing.T) { + t.Run("Text Content", func(t *testing.T) { + textBody := []byte(`{"user":"john","password":"test-pass-placeholder"}`) // pragma: allowlist secret + text, encoding := processBodyContent(textBody, true, "application/json") + if encoding != "" { + t.Fatal("text content should not have encoding") + } + if !strings.Contains(text, "[REDACTED_PASSWORD]") || strings.Contains(text, "testPass") { + t.Fatal("password in text body should be redacted") + } + }) + + t.Run("Binary Content", func(t *testing.T) { + binaryBody := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + } + text, encoding := processBodyContent(binaryBody, false, "") + if encoding != "base64" { + t.Fatalf("binary content should be base64 encoded, got %q", encoding) + } + if _, err := base64.StdEncoding.DecodeString(text); err != nil { + t.Fatalf("invalid base64 encoding: %v", err) + } + }) + + t.Run("Empty Body", func(t *testing.T) { + text, encoding := processBodyContent([]byte{}, false, "") + if text != "" || encoding != "" { + t.Fatal("empty body should return empty strings") + } + }) +} + +func TestIsBinaryContent(t *testing.T) { + tests := []struct { + name string + data []byte + isBinary bool + }{ + {"Plain Text", []byte("This is plain text"), false}, + {"JSON", []byte(`{"key":"value"}`), false}, + {"PNG Header", []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, true}, + {"PDF Header", []byte{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34}, false}, + {"Empty", []byte{}, false}, + {"Text with Newlines", []byte("Line 1\nLine 2\nLine 3"), false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := isBinaryContent(tc.data) + if got != tc.isBinary { + t.Fatalf("isBinaryContent=%v, want %v", got, tc.isBinary) + } + }) + } +} + +func TestBuildHARRequest(t *testing.T) { + reqURL, _ := url.Parse("https://api.example.com/v1/resource?key=value&token=test") + req := &http.Request{ + Method: "POST", + URL: reqURL, + Proto: "HTTP/1.1", + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer fake-test-bearer-token"}, // pragma: allowlist secret + }, + } + body := []byte(`{"data":"value"}`) + + harReq := buildHARRequest(req, body, "application/json") + + if harReq.Method != "POST" { + t.Fatalf("method=%s, want POST", harReq.Method) + } + if harReq.HTTPVersion != "HTTP/1.1" { + t.Fatalf("httpVersion=%s, want HTTP/1.1", harReq.HTTPVersion) + } + if len(harReq.QueryString) != 2 { + t.Fatalf("expected 2 query params, got %d", len(harReq.QueryString)) + } + authHeaderFound := false + for _, h := range harReq.Headers { + if h.Name == "Authorization" { + authHeaderFound = true + if !strings.Contains(h.Value, "[REDACTED_") || strings.Contains(h.Value, "fake-test-bearer") { + t.Fatal("authorization header not redacted in HAR request") + } + } + } + if !authHeaderFound { + t.Fatal("authorization header missing from HAR request") + } + if harReq.PostData == nil || harReq.PostData.MimeType != "application/json" { + t.Fatal("postData missing or wrong MIME type") + } +} + +func TestBuildHARResponse(t *testing.T) { + resp := &http.Response{ + StatusCode: 200, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Set-Cookie": []string{"session=test-session-id-for-testing"}, // pragma: allowlist secret + }, + } + body := []byte(`{"result":"success","token":"test-api-token-val"}`) // pragma: allowlist secret + + harResp := buildHARResponse(resp, nil, body, "application/json", nil) + + if harResp.Status != 200 { + t.Fatalf("status=%d, want 200", harResp.Status) + } + cookieFound := false + for _, h := range harResp.Headers { + if h.Name == "Set-Cookie" { + cookieFound = true + if !strings.Contains(h.Value, "[REDACTED_") { + t.Fatal("Set-Cookie header not redacted") + } + } + } + if !cookieFound { + t.Fatal("Set-Cookie header missing") + } + if !strings.Contains(harResp.Content.Text, "[REDACTED_") || strings.Contains(harResp.Content.Text, "test-api-token-val") { + t.Fatal("response body token not redacted") + } +} + +func TestHARArchiveStructure(t *testing.T) { + archive := createNewHARArchive() + if archive.Log.Version != "1.2" { + t.Fatalf("version=%s, want 1.2", archive.Log.Version) + } + if archive.Log.Creator.Name != "ibm-go-sdk-core" { + t.Fatal("wrong creator name") + } + if archive.Log.Entries == nil || len(archive.Log.Entries) != 0 { + t.Fatal("new archive should have 0 entries") + } +} + +func TestHARAppendWithCopies(t *testing.T) { + tmpFile := "/tmp/test-har-" + time.Now().Format("20060102-150405") + ".har" + defer os.Remove(tmpFile) + + os.Setenv("HAR_ENABLED", "1") + os.Setenv("HAR_FILE_PATH", tmpFile) + harOnce = sync.Once{} + + reqURL, _ := url.Parse("https://api.example.com/test") + req := &http.Request{ + Method: "GET", + URL: reqURL, + Proto: "HTTP/1.1", + Header: http.Header{ + "Authorization": []string{"Bearer test-bearer-token-val"}, // pragma: allowlist secret + }, + } + + resp := &http.Response{ + StatusCode: 200, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + } + + startTime := time.Now() + endTime := startTime.Add(100 * time.Millisecond) + + reqBody := []byte(`{"request":"data"}`) + respBody := []byte(`{"response":"data"}`) + + HARAppendWithCopies(req, resp, startTime, endTime, nil, reqBody, respBody, "application/json", "application/json") + + data, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("failed to read HAR file: %v", err) + } + + var archive harArchive + if err := json.Unmarshal(data, &archive); err != nil { + t.Fatalf("failed to parse HAR file: %v", err) + } + + if len(archive.Log.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(archive.Log.Entries)) + } + + entry := archive.Log.Entries[0] + authFound := false + for _, h := range entry.Request.Headers { + if h.Name == "Authorization" { + authFound = true + if !strings.Contains(h.Value, "[REDACTED_") || strings.Contains(h.Value, "test-bearer-token") { + t.Fatal("authorization header not redacted in HAR file") + } + } + } + if !authFound { + t.Fatal("authorization header missing from HAR entry") + } + if entry.Request.Method != "GET" { + t.Fatalf("method=%s, want GET", entry.Request.Method) + } + if entry.Response.Status != 200 { + t.Fatalf("status=%d, want 200", entry.Response.Status) + } +} + +func TestMultipleHAREntries(t *testing.T) { + tmpFile := "/tmp/test-har-multi-" + time.Now().Format("20060102-150405") + ".har" + defer os.Remove(tmpFile) + + os.Setenv("HAR_ENABLED", "1") + os.Setenv("HAR_FILE_PATH", tmpFile) + harOnce = sync.Once{} + + for i := 0; i < 3; i++ { + reqURL, _ := url.Parse("https://api.example.com/test") + req := &http.Request{ + Method: "GET", + URL: reqURL, + Proto: "HTTP/1.1", + } + resp := &http.Response{ + StatusCode: 200, + Status: "200 OK", + Proto: "HTTP/1.1", + } + HARAppendWithCopies(req, resp, time.Now(), time.Now(), nil, nil, nil, "", "") + } + + data, _ := os.ReadFile(tmpFile) + var archive harArchive + _ = json.Unmarshal(data, &archive) + + if len(archive.Log.Entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(archive.Log.Entries)) + } +}