From d548728d57f6150639c5c0fe962143a8adb4eb28 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 16 Jul 2026 15:36:47 -0600 Subject: [PATCH 1/6] redact customer data from telemetry http paths --- internal/pkg/client/path_params_test.go | 5 ++ internal/pkg/inputguard/inputguard.go | 60 ++++++++++++++++++++++ internal/pkg/inputguard/inputguard_test.go | 48 +++++++++++++++++ internal/pkg/telemetry/telemetry.go | 3 +- internal/pkg/telemetry/telemetry_test.go | 58 +++++++++++++++++++++ 5 files changed, 173 insertions(+), 1 deletion(-) diff --git a/internal/pkg/client/path_params_test.go b/internal/pkg/client/path_params_test.go index c6bf725..4565f80 100644 --- a/internal/pkg/client/path_params_test.go +++ b/internal/pkg/client/path_params_test.go @@ -119,6 +119,11 @@ func TestParsePathParams(t *testing.T) { path: "/varsets/{varset_id}/relationships/vars", expected: map[string]string{"varset_id": "varsets"}, }, + { + name: "unaccompanied braces", + path: "/varsets/{varset_id/oops", + expected: map[string]string{}, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/pkg/inputguard/inputguard.go b/internal/pkg/inputguard/inputguard.go index 718204d..5f80c29 100644 --- a/internal/pkg/inputguard/inputguard.go +++ b/internal/pkg/inputguard/inputguard.go @@ -13,6 +13,8 @@ package inputguard import ( "fmt" + "regexp" + "strings" "unicode" "unicode/utf8" ) @@ -21,6 +23,23 @@ import ( // so a pathological input cannot flood the terminal. const maxErrorValueLen = 80 +var ( + // Redactions should use capture groups to identify sensitive segments of a path, which + // will be replaced with a redaction placeholder. + redactions = []*regexp.Regexp{ + // API paths: + regexp.MustCompile("/organizations/([^/]+)/workspaces/([^/]+)"), + regexp.MustCompile("/organizations/([^/]+)"), + + // Archivist Paths: + regexp.MustCompile("/v1/object/([a-zA-Z0-9]+)"), + + // Registry paths: + regexp.MustCompile("/v1/modules/([^/]+)"), + regexp.MustCompile("/registry-providers/private/([^/]+)/([^/]+)"), + } +) + // InvalidInputError describes why a value failed validation. The offending value // is always rendered with %q so control characters are escaped rather than // written raw to a terminal. @@ -63,3 +82,44 @@ func Validate(s string) error { return nil } + +// RedactPath returns a version of an HTTP path suitable for logging or error messages, with +// sensitive segments replaced with a redaction placeholder. It is +// intended for use in telemetry and audit logs, not for security-critical +// redaction of secrets. +func RedactPath(path string) string { + for _, re := range redactions { + // Find the start/end indexes of the full match AND all capture groups + indices := re.FindStringSubmatchIndex(path) + if len(indices) == 0 { + continue + } + + var result strings.Builder + lastIndex := 0 + + // indices[0] and indices[1] are the start/end of the FULL match. + // Capture groups start at index index 2 (indices[2] to indices[3] is Group 1, etc.) + for i := 1; i < len(indices)/2; i++ { + groupStart := indices[2*i] + groupEnd := indices[2*i+1] + + // Handle cases where an optional group didn't match + if groupStart < 0 || groupEnd < 0 { + continue + } + + result.WriteString(path[lastIndex:groupStart]) + result.WriteString("") + lastIndex = groupEnd + } + + // Write whatever remains of the path after the last redacted group + result.WriteString(path[lastIndex:]) + + // Update path and return (or continue to next regex if paths can match multiple rules) + path = result.String() + } + + return path +} diff --git a/internal/pkg/inputguard/inputguard_test.go b/internal/pkg/inputguard/inputguard_test.go index 10db262..ab8cec9 100644 --- a/internal/pkg/inputguard/inputguard_test.go +++ b/internal/pkg/inputguard/inputguard_test.go @@ -64,3 +64,51 @@ func TestInvalidInputErrorEscapesAndTruncates(t *testing.T) { t.Fatalf("expected truncated value in %q", msg) } } + +func TestRedactPath(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "organization path", + input: "/api/v2/organizations/my-org", + expected: "/api/v2/organizations/", + }, + { + name: "workspace path", + input: "/api/v2/organizations/my-org/workspaces/my-workspace", + expected: "/api/v2/organizations//workspaces/", + }, + { + name: "archivist path", + input: "/v1/object/abc123", + expected: "/v1/object/", + }, + { + name: "projects path", + input: "/organizations/my-org/projects", + expected: "/organizations//projects", + }, + { + name: "don't redact too much", + input: "/api/v2/organizations/organizations/workspaces/organizations", + expected: "/api/v2/organizations//workspaces/", + }, + { + name: "registry provider path", + input: "/registry-providers/private/hashicorp/aws", + expected: "/registry-providers/private//", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := RedactPath(tc.input) + if result != tc.expected { + t.Errorf("RedactPath(%q) = %q; want %q", tc.input, result, tc.expected) + } + }) + } +} diff --git a/internal/pkg/telemetry/telemetry.go b/internal/pkg/telemetry/telemetry.go index b4ccf19..56281ed 100644 --- a/internal/pkg/telemetry/telemetry.go +++ b/internal/pkg/telemetry/telemetry.go @@ -28,6 +28,7 @@ import ( "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" + "github.com/hashicorp/tfctl-cli/internal/pkg/inputguard" "github.com/hashicorp/tfctl-cli/internal/pkg/profile" "github.com/hashicorp/tfctl-cli/skills" "github.com/hashicorp/tfctl-cli/version" @@ -226,7 +227,7 @@ func detectAgent() string { func (t *Telemetry) StartNetwork(ctx context.Context, name string, req *http.Request) (context.Context, trace.Span) { // Build attributes from Request attrs := []attribute.KeyValue{ - attribute.String("http.path", req.URL.Path), + attribute.String("http.path", inputguard.RedactPath(req.URL.Path)), attribute.String("http.method", req.Method), } diff --git a/internal/pkg/telemetry/telemetry_test.go b/internal/pkg/telemetry/telemetry_test.go index 87d6623..ba4d4b5 100644 --- a/internal/pkg/telemetry/telemetry_test.go +++ b/internal/pkg/telemetry/telemetry_test.go @@ -377,6 +377,64 @@ func TestStartNetwork_ParentIsCommandSpan(t *testing.T) { "network span must belong to the same trace as the command span") } +func TestStartNetwork_RedactsSensitivePaths(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "archivist path", + input: "https://archivist.terraform.io/v1/object/asjfgahsdgljashdglsdfjnnnnnOJSFDHGKSNB142", + expected: "/v1/object/", + }, + { + name: "projects index", + input: "https://app.terraform.io/api/v2/organizations/my-org/projects", + expected: "/api/v2/organizations//projects", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearEnv(t) + + tel, exporter := newTestTelemetry(t) + + // Start the command span — returns a context carrying the command span. + cmdCtx := tel.StartCommand(context.Background(), CommandInfo{ + Command: "api get", + Profile: profile.TestProfile(t), + }) + + req, err := http.NewRequestWithContext(cmdCtx, http.MethodGet, tc.input, nil) + require.NoError(t, err) + + _, netSpan := tel.StartNetwork(cmdCtx, "HTTP GET", req) + netSpan.End() + tel.span.End() + + require.NoError(t, tel.sdkTP.ForceFlush(context.Background())) + + spans := exporter.GetSpans() + require.Len(t, spans, 2) + + // Identify spans by name. + var networkSpan tracetest.SpanStub + for _, s := range spans { + if s.Name == "HTTP GET" { + networkSpan = s + } + } + + require.NotEmpty(t, networkSpan.Name, "network span not found") + + attrs := spanAttrMap(networkSpan) + assert.Equal(t, tc.expected, attrs["http.path"]) + }) + } +} + func TestStartNetwork_WithoutCommandContext_NoParent(t *testing.T) { clearEnv(t) From d2697a2adc8b4888510c15dd71ae6753a3f4e62a Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 16 Jul 2026 15:37:54 -0600 Subject: [PATCH 2/6] Update TELEMETRY.md --- TELEMETRY.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index 4cb14c8..ff7591d 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -43,11 +43,11 @@ HashiCorp does not collect or transmit your data and takes steps to redact and o ### Network Request Span (One span per network request) -| Key | Example Value | Source | -|---------------------|-------------------|-------------------------------------| -| http.path | “/workspaces/:id” | API request path, with IDs redacted | -| http.method | "GET" | API request verb | -| http.status_code | 200 | HTTP response status | -| http.content_length | 121070 | Response byte size | -| http.duration_ms | 855 | Request latency | +| Key | Example Value | Source | +|---------------------|--------------------------|-------------------------------------| +| http.path | “/workspaces/” | API request path, with IDs redacted | +| http.method | "GET" | API request verb | +| http.status_code | 200 | HTTP response status | +| http.content_length | 121070 | Response byte size | +| http.duration_ms | 855 | Request latency | From f1f940a42d3d6840558cf8b8685b1eeec2259324 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 16 Jul 2026 15:39:37 -0600 Subject: [PATCH 3/6] Create BUG FIXES-20260716-153934.yaml --- .changes/unreleased/BUG FIXES-20260716-153934.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changes/unreleased/BUG FIXES-20260716-153934.yaml diff --git a/.changes/unreleased/BUG FIXES-20260716-153934.yaml b/.changes/unreleased/BUG FIXES-20260716-153934.yaml new file mode 100644 index 0000000..ea48f51 --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260716-153934.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: Redacts customer data within HTTP paths in telemetry payloads, such as organization and workspace names. +time: 2026-07-16T15:39:34.248018-06:00 From e0fe6ceeee4714ca10412f3ca96470b23b743010 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 16 Jul 2026 15:56:11 -0600 Subject: [PATCH 4/6] don't redact archivist hostnames --- internal/pkg/telemetry/telemetry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/telemetry/telemetry.go b/internal/pkg/telemetry/telemetry.go index 56281ed..067e83b 100644 --- a/internal/pkg/telemetry/telemetry.go +++ b/internal/pkg/telemetry/telemetry.go @@ -265,7 +265,7 @@ func (t *Telemetry) StartCommand(ctx context.Context, info CommandInfo) context. attribute.Bool("is_named_profile", info.Profile.Name != profile.ProfileNameDefault), ) hostname := info.Profile.GetHostname() - if !info.Profile.IsHCPTerraform() { + if !info.Profile.IsHCPTerraform() && hostname != "archivist.terraform.io" && hostname != "archivist.eu.terraform.io" { hostname = generateStableID(info.Profile.GetHostname(), 0) } From b20f1f4095e15c76c16dbceca7343923fb9d0775 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 16 Jul 2026 16:10:47 -0600 Subject: [PATCH 5/6] fixes --- TELEMETRY.md | 14 +++++++------- internal/pkg/inputguard/inputguard.go | 6 +++--- internal/pkg/inputguard/inputguard_test.go | 5 +++++ internal/pkg/profile/profile.go | 6 ------ internal/pkg/telemetry/telemetry.go | 2 +- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index ff7591d..1c63478 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -43,11 +43,11 @@ HashiCorp does not collect or transmit your data and takes steps to redact and o ### Network Request Span (One span per network request) -| Key | Example Value | Source | -|---------------------|--------------------------|-------------------------------------| -| http.path | “/workspaces/” | API request path, with IDs redacted | -| http.method | "GET" | API request verb | -| http.status_code | 200 | HTTP response status | -| http.content_length | 121070 | Response byte size | -| http.duration_ms | 855 | Request latency | +| Key | Example Value | Source | +|---------------------|-----------------------------|-------------------------------------| +| http.path | “/organizations/” | API request path, with IDs redacted | +| http.method | "GET" | API request verb | +| http.status_code | 200 | HTTP response status | +| http.content_length | 121070 | Response byte size | +| http.duration_ms | 855 | Request latency | diff --git a/internal/pkg/inputguard/inputguard.go b/internal/pkg/inputguard/inputguard.go index 5f80c29..3f17460 100644 --- a/internal/pkg/inputguard/inputguard.go +++ b/internal/pkg/inputguard/inputguard.go @@ -35,7 +35,7 @@ var ( regexp.MustCompile("/v1/object/([a-zA-Z0-9]+)"), // Registry paths: - regexp.MustCompile("/v1/modules/([^/]+)"), + regexp.MustCompile("/v1/modules/([^/]+)/([^/]+)/([^/]+)"), regexp.MustCompile("/registry-providers/private/([^/]+)/([^/]+)"), } ) @@ -99,7 +99,7 @@ func RedactPath(path string) string { lastIndex := 0 // indices[0] and indices[1] are the start/end of the FULL match. - // Capture groups start at index index 2 (indices[2] to indices[3] is Group 1, etc.) + // Capture groups start at index 2 (indices[2] to indices[3] is Group 1, etc.) for i := 1; i < len(indices)/2; i++ { groupStart := indices[2*i] groupEnd := indices[2*i+1] @@ -117,7 +117,7 @@ func RedactPath(path string) string { // Write whatever remains of the path after the last redacted group result.WriteString(path[lastIndex:]) - // Update path and return (or continue to next regex if paths can match multiple rules) + // Update path and continue to next regex path = result.String() } diff --git a/internal/pkg/inputguard/inputguard_test.go b/internal/pkg/inputguard/inputguard_test.go index ab8cec9..8125bd2 100644 --- a/internal/pkg/inputguard/inputguard_test.go +++ b/internal/pkg/inputguard/inputguard_test.go @@ -101,6 +101,11 @@ func TestRedactPath(t *testing.T) { input: "/registry-providers/private/hashicorp/aws", expected: "/registry-providers/private//", }, + { + name: "registry v1 modules path", + input: "/v1/modules/hashicorp/consul/aws/versions", + expected: "/v1/modules////versions", + }, } for _, tc := range tests { diff --git a/internal/pkg/profile/profile.go b/internal/pkg/profile/profile.go index 96bb611..262d3bf 100644 --- a/internal/pkg/profile/profile.go +++ b/internal/pkg/profile/profile.go @@ -342,12 +342,6 @@ func NormalizeHostname(hostname string) (string, error) { return hostname, nil } -// IsHCPTerraform returns true if the profile's hostname is a known HCP Terraform hostname -// in any geo. -func (p *Profile) IsHCPTerraform() bool { - return p.GetHostname() == "app.terraform.io" || p.GetHostname() == "app.eu.terraform.io" -} - // SetDefaultOrganization sets the default organization. func (p *Profile) SetDefaultOrganization(name string) *Profile { if p == nil { diff --git a/internal/pkg/telemetry/telemetry.go b/internal/pkg/telemetry/telemetry.go index 067e83b..5197a8c 100644 --- a/internal/pkg/telemetry/telemetry.go +++ b/internal/pkg/telemetry/telemetry.go @@ -265,7 +265,7 @@ func (t *Telemetry) StartCommand(ctx context.Context, info CommandInfo) context. attribute.Bool("is_named_profile", info.Profile.Name != profile.ProfileNameDefault), ) hostname := info.Profile.GetHostname() - if !info.Profile.IsHCPTerraform() && hostname != "archivist.terraform.io" && hostname != "archivist.eu.terraform.io" { + if !strings.HasSuffix(hostname, ".terraform.io") { hostname = generateStableID(info.Profile.GetHostname(), 0) } From 996d0df09efffbb888262a1f4a783b4ff97ffb4b Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 16 Jul 2026 16:12:32 -0600 Subject: [PATCH 6/6] Update telemetry_test.go --- internal/pkg/telemetry/telemetry_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/telemetry/telemetry_test.go b/internal/pkg/telemetry/telemetry_test.go index ba4d4b5..474ea02 100644 --- a/internal/pkg/telemetry/telemetry_test.go +++ b/internal/pkg/telemetry/telemetry_test.go @@ -287,7 +287,7 @@ func TestStartCommand_CreatesSpanWithAttributes(t *testing.T) { assert.Equal(t, false, attrs["is_tty"]) assert.NotEmpty(t, attrs["os"]) assert.NotEmpty(t, attrs["arch"]) - assert.NotEqual(t, attrs["hostname"], p.GetHostname(), "Value must be a hashed value of the test profile hostname") + assert.Equal(t, attrs["hostname"], p.GetHostname()) assert.NotEmpty(t, attrs["hostname"]) }