Skip to content

Commit e70690d

Browse files
authored
Make x-sensitive data marshal return raw (#27)
1 parent bfd3f11 commit e70690d

10 files changed

Lines changed: 465 additions & 1055 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ When debugging complex issues, create a minimal reproducible example:
107107
### File organization
108108
- `testdata/specs` - Specs being tested (if missing: run `make fetch-specs` to download)
109109
- Never put generated files in project root - use `/tmp` for testing
110+
- Never build binaries in project root - use `go run ./cmd/oapi-codegen` instead of `go build`
110111

111112
## Code Generation Architecture
112113

docs/extensions/x-sensitive-data.md

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
# `x-sensitive-data`
22

3-
Automatically mask sensitive data in JSON output.
3+
Automatically mask sensitive data in structured logs.
44

55
## Overview
66

7-
The `x-sensitive-data` extension allows you to mark fields as containing sensitive information that should be automatically masked when marshaling to JSON. This is useful for preventing accidental logging or exposure of sensitive data like passwords, API keys, credit card numbers, etc.
7+
The `x-sensitive-data` extension allows you to mark fields as containing sensitive information that should be automatically masked when logging. This is useful for preventing accidental exposure of sensitive data like passwords, API keys, credit card numbers, etc. in logs.
8+
9+
The extension generates two methods:
10+
11+
- **`Masked()`** - Returns a copy of the struct with sensitive fields masked
12+
- **`LogValue()`** - Implements Go's [`slog.LogValuer`](https://pkg.go.dev/log/slog#LogValuer) interface (calls `Masked()` internally)
13+
14+
This means:
15+
16+
- **JSON serialization stays raw** - `json.Marshal(user)` returns the actual values (needed for API calls)
17+
- **Masked JSON** - `json.Marshal(user.Masked())` returns masked values
18+
- **Structured logging is masked** - `slog.Info("user", "user", user)` automatically masks sensitive fields
819

920
## Masking Strategies
1021

@@ -23,20 +34,44 @@ The extension supports several masking strategies:
2334

2435
## Generated Code
2536

26-
This generates:
37+
This generates a struct with `Masked()` and `LogValue()` methods:
2738

2839
```go
29-
--8<-- "extensions/xsensitivedata/basic/gen.go:16:23"
40+
--8<-- "extensions/xsensitivedata/basic/gen.go:14:77"
3041
```
3142

3243
## Behavior
3344

34-
When marshaling to JSON:
45+
When logging with slog:
3546

36-
- `email: "user@example.com"` becomes `email: "********"` (fixed length, hides original length)
37-
- `ssn: "123-45-6789"` becomes `ssn: "***-**-****"` (digits masked, structure visible)
38-
- `creditCard: "1234-5678-9012-3456"` becomes `creditCard: "********3456"` (last 4 visible)
39-
- `apiKey: "my-secret-key"` becomes `apiKey: "325ededd6c3b9988f623c7f964abb9b016b76b0f8b3474df0f7d7c23b941381f"` (SHA256 hash)
47+
```go
48+
user := User{
49+
ID: 1,
50+
Username: "johndoe",
51+
Email: Ptr("user@example.com"),
52+
Ssn: Ptr("123-45-6789"),
53+
CreditCard: Ptr("1234-5678-9012-3456"),
54+
APIKey: Ptr("my-secret-key"),
55+
}
56+
57+
// Masked in logs
58+
slog.Info("user created", "user", user)
59+
// Output: user={id=1 username=johndoe email=******** ssn=***-**-**** creditCard=********3456 apiKey=325ededd...}
60+
61+
// Raw in JSON (for API calls)
62+
json.Marshal(user)
63+
// Output: {"id":1,"username":"johndoe","email":"user@example.com","ssn":"123-45-6789",...}
64+
```
65+
66+
## Masked JSON Output
67+
68+
If you need masked JSON (e.g., for API responses that should hide sensitive data), use the `Masked()` method:
69+
70+
```go
71+
// Get masked JSON
72+
maskedJSON, _ := json.Marshal(user.Masked())
73+
// Output: {"id":1,"username":"johndoe","email":"********","ssn":"***-**-****",...}
74+
```
4075

4176
## Partial Masking Options
4277

examples/extensions/xsensitivedata/basic/gen.go

Lines changed: 17 additions & 60 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/extensions/xsensitivedata/basic/gen_test.go

Lines changed: 50 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package xsensitivedata
22

33
import (
44
"encoding/json"
5-
"strings"
65
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
79
)
810

9-
func TestUserMarshalJSON_SensitiveData(t *testing.T) {
11+
func TestUserMarshalJSON_RawData(t *testing.T) {
1012
email := "user@example.com"
1113
ssn := "123-45-6789"
1214
creditCard := "1234-5678-9012-3456"
@@ -21,66 +23,64 @@ func TestUserMarshalJSON_SensitiveData(t *testing.T) {
2123
APIKey: &apiKey,
2224
}
2325

26+
// json.Marshal returns raw data (for API calls)
2427
data, err := json.Marshal(user)
25-
if err != nil {
26-
t.Fatalf("Failed to marshal user: %v", err)
27-
}
28+
require.NoError(t, err)
2829

2930
jsonStr := string(data)
30-
t.Logf("Marshaled JSON: %s", jsonStr)
3131

32-
// Verify that sensitive data is masked
33-
if strings.Contains(jsonStr, "user@example.com") {
34-
t.Error("Email should be masked but found in JSON")
35-
}
32+
// Verify that sensitive data is NOT masked in raw JSON
33+
assert.Contains(t, jsonStr, "user@example.com", "Email should be present in raw JSON")
34+
assert.Contains(t, jsonStr, "123-45-6789", "SSN should be present in raw JSON")
35+
assert.Contains(t, jsonStr, "1234-5678-9012-3456", "Credit card should be present in raw JSON")
36+
assert.Contains(t, jsonStr, "my-secret-api-key", "API key should be present in raw JSON")
37+
}
3638

37-
if strings.Contains(jsonStr, "123-45-6789") {
38-
t.Error("SSN should be masked but found in JSON")
39-
}
39+
func TestUserMasked_SensitiveData(t *testing.T) {
40+
email := "user@example.com"
41+
ssn := "123-45-6789"
42+
creditCard := "1234-5678-9012-3456"
43+
apiKey := "my-secret-api-key"
4044

41-
if strings.Contains(jsonStr, "1234-5678-9012-3456") {
42-
t.Error("Credit card should be partially masked but found in JSON")
45+
user := User{
46+
ID: 1,
47+
Username: "testuser",
48+
Email: &email,
49+
Ssn: &ssn,
50+
CreditCard: &creditCard,
51+
APIKey: &apiKey,
4352
}
4453

45-
// Verify credit card shows last 4 digits
46-
if !strings.Contains(jsonStr, "3456") {
47-
t.Error("Credit card should show last 4 digits")
48-
}
54+
// json.Marshal(user.Masked()) returns masked data
55+
data, err := json.Marshal(user.Masked())
56+
require.NoError(t, err)
4957

50-
if strings.Contains(jsonStr, "my-secret-api-key") {
51-
t.Error("API key should be hashed but found in JSON")
52-
}
58+
jsonStr := string(data)
5359

54-
// Verify that non-sensitive data is present
55-
if !strings.Contains(jsonStr, `"id":1`) {
56-
t.Error("ID should be present in JSON")
57-
}
60+
// Verify that sensitive data is masked
61+
assert.NotContains(t, jsonStr, "user@example.com", "Email should be masked")
62+
assert.NotContains(t, jsonStr, "123-45-6789", "SSN should be masked")
63+
assert.NotContains(t, jsonStr, "1234-5678-9012-3456", "Credit card should be partially masked")
64+
assert.NotContains(t, jsonStr, "my-secret-api-key", "API key should be hashed")
5865

59-
if !strings.Contains(jsonStr, `"username":"testuser"`) {
60-
t.Error("Username should be present in JSON")
61-
}
66+
// Verify credit card shows last 4 digits
67+
assert.Contains(t, jsonStr, "3456", "Credit card should show last 4 digits")
68+
69+
// Verify that non-sensitive data is present
70+
assert.Contains(t, jsonStr, `"id":1`, "ID should be present")
71+
assert.Contains(t, jsonStr, `"username":"testuser"`, "Username should be present")
6272

6373
// Verify that email is masked (should be fixed-length asterisks)
6474
var result map[string]interface{}
65-
if err := json.Unmarshal(data, &result); err != nil {
66-
t.Fatalf("Failed to unmarshal JSON: %v", err)
67-
}
75+
require.NoError(t, json.Unmarshal(data, &result))
6876

69-
// Use the same constant as the runtime for consistency
70-
expectedMask := "********" // runtime.maskReplacement is not exported
71-
72-
if email, ok := result["email"].(string); ok {
73-
if email != expectedMask {
74-
t.Errorf("Email should be masked as '%s', got: %s", expectedMask, email)
75-
}
76-
}
77+
expectedMask := "********"
78+
assert.Equal(t, expectedMask, result["email"], "Email should be masked as asterisks")
7779

7880
// Verify that API key is hashed (should be a hex string)
79-
if apiKey, ok := result["apiKey"].(string); ok {
80-
if len(apiKey) != 64 { // SHA256 produces 64 hex characters
81-
t.Errorf("API key should be a SHA256 hash (64 chars), got length: %d", len(apiKey))
82-
}
83-
}
81+
apiKeyVal, ok := result["apiKey"].(string)
82+
require.True(t, ok, "apiKey should be a string")
83+
assert.Len(t, apiKeyVal, 64, "API key should be a SHA256 hash (64 chars)")
8484
}
8585

8686
func TestUserUnmarshalJSON(t *testing.T) {
@@ -94,19 +94,10 @@ func TestUserUnmarshalJSON(t *testing.T) {
9494
}`
9595

9696
var user User
97-
if err := json.Unmarshal([]byte(jsonStr), &user); err != nil {
98-
t.Fatalf("Failed to unmarshal user: %v", err)
99-
}
97+
require.NoError(t, json.Unmarshal([]byte(jsonStr), &user))
10098

101-
if user.ID != 1 {
102-
t.Errorf("Expected ID 1, got %d", user.ID)
103-
}
104-
105-
if user.Username != "testuser" {
106-
t.Errorf("Expected username 'testuser', got '%s'", user.Username)
107-
}
108-
109-
if user.Email == nil || *user.Email != "user@example.com" {
110-
t.Errorf("Expected email 'user@example.com', got %v", user.Email)
111-
}
99+
assert.Equal(t, int64(1), user.ID)
100+
assert.Equal(t, "testuser", user.Username)
101+
require.NotNil(t, user.Email)
102+
assert.Equal(t, "user@example.com", *user.Email)
112103
}

0 commit comments

Comments
 (0)