Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .changes/unreleased/BUG FIXES-20260716-153934.yaml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 7 additions & 7 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | “/organizations/<redacted>” | 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 |

5 changes: 5 additions & 0 deletions internal/pkg/client/path_params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
60 changes: 60 additions & 0 deletions internal/pkg/inputguard/inputguard.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ package inputguard

import (
"fmt"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
Expand All @@ -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{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module regex only redacts the namespace. I ran this against a realistic path and the module name and provider still leak:
/v1/modules/hashicorp/consul/aws/1.0.0/download/v1/modules/<redacted>/consul/aws/1.0.0/download
Compare with the registry-providers rule just below, which correctly redacts two segments. For private modules the name/provider are customer data too so should this be /v1/modules/([^/]+)/([^/]+)/([^/]+) or similar?

// 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.
Expand Down Expand Up @@ -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 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("<redacted>")
lastIndex = groupEnd
}

// Write whatever remains of the path after the last redacted group
result.WriteString(path[lastIndex:])

// Update path and continue to next regex
path = result.String()
}

return path
}
53 changes: 53 additions & 0 deletions internal/pkg/inputguard/inputguard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,56 @@ 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/<redacted>",
},
{
name: "workspace path",
input: "/api/v2/organizations/my-org/workspaces/my-workspace",
expected: "/api/v2/organizations/<redacted>/workspaces/<redacted>",
},
{
name: "archivist path",
input: "/v1/object/abc123",
expected: "/v1/object/<redacted>",
},
{
name: "projects path",
input: "/organizations/my-org/projects",
expected: "/organizations/<redacted>/projects",
},
{
name: "don't redact too much",
input: "/api/v2/organizations/organizations/workspaces/organizations",
expected: "/api/v2/organizations/<redacted>/workspaces/<redacted>",
},
{
name: "registry provider path",
input: "/registry-providers/private/hashicorp/aws",
expected: "/registry-providers/private/<redacted>/<redacted>",
},
{
name: "registry v1 modules path",
input: "/v1/modules/hashicorp/consul/aws/versions",
expected: "/v1/modules/<redacted>/<redacted>/<redacted>/versions",
},
}

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)
}
})
}
}
6 changes: 0 additions & 6 deletions internal/pkg/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions internal/pkg/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
}

Expand Down Expand Up @@ -264,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 !strings.HasSuffix(hostname, ".terraform.io") {
hostname = generateStableID(info.Profile.GetHostname(), 0)
}

Expand Down
60 changes: 59 additions & 1 deletion internal/pkg/telemetry/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}

Expand Down Expand Up @@ -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/<redacted>",
},
{
name: "projects index",
input: "https://app.terraform.io/api/v2/organizations/my-org/projects",
expected: "/api/v2/organizations/<redacted>/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)

Expand Down