Skip to content

Commit c09ccf9

Browse files
committed
security: reduce information leakage when displaying the tokens
1 parent 81696f4 commit c09ccf9

File tree

4 files changed

+217
-20
lines changed

4 files changed

+217
-20
lines changed

cmd/set_token_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func TestRunSetToken(t *testing.T) {
7777
return configFile
7878
},
7979
expectedOutputs: []string{
80-
"Successfully set token for test.example.com: test****-123",
80+
"Successfully set token for test.example.com: test********",
8181
"Config saved to:",
8282
},
8383
},
@@ -95,7 +95,7 @@ func TestRunSetToken(t *testing.T) {
9595
mockStdin: "interactive-token-456\n",
9696
expectedOutputs: []string{
9797
"Enter token for test.example.com:",
98-
"Successfully set token for test.example.com: inte****-456",
98+
"Successfully set token for test.example.com: inte********",
9999
},
100100
},
101101
{
@@ -114,7 +114,7 @@ func TestRunSetToken(t *testing.T) {
114114
return configFile
115115
},
116116
expectedOutputs: []string{
117-
"Successfully set token for test.example.com: new-****-789",
117+
"Successfully set token for test.example.com: ********",
118118
},
119119
},
120120
{
@@ -131,7 +131,7 @@ func TestRunSetToken(t *testing.T) {
131131
},
132132
mockStdin: "n\n",
133133
expectedOutputs: []string{
134-
"Token already exists for test.example.com: old-****-123",
134+
"Token already exists for test.example.com: ********",
135135
"Replace it? (y/N):",
136136
"Operation cancelled",
137137
},
@@ -151,9 +151,9 @@ func TestRunSetToken(t *testing.T) {
151151
},
152152
mockStdin: "y\n",
153153
expectedOutputs: []string{
154-
"Token already exists for test.example.com: old-****-123",
154+
"Token already exists for test.example.com: ********",
155155
"Replace it? (y/N):",
156-
"Successfully set token for test.example.com: new-****-789",
156+
"Successfully set token for test.example.com: ********",
157157
},
158158
},
159159
{
@@ -190,7 +190,7 @@ func TestRunSetToken(t *testing.T) {
190190
expectedOutputs: []string{
191191
"Validating token with test-provider provider...",
192192
"Token validated successfully",
193-
"Successfully set token for test.example.com: vali****oken",
193+
"Successfully set token for test.example.com: ********",
194194
},
195195
},
196196
{
@@ -320,7 +320,7 @@ func TestRunSetToken(t *testing.T) {
320320
expectedOutputs: []string{
321321
"Detected test-provider provider, validating token...",
322322
"Warning: token may not be valid",
323-
"Successfully set token for test.example.com: mayb****oken",
323+
"Successfully set token for test.example.com: mayb********",
324324
},
325325
expectError: false,
326326
},

cmd/status_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func TestRunStatus(t *testing.T) {
5454
setupConfig: func(t *testing.T) string {
5555
tmpDir := t.TempDir()
5656
configFile := filepath.Join(tmpDir, "nix.conf")
57-
content := `access-tokens = github.com=gho_testtoken123
57+
content := `access-tokens = github.com=gho_testtoken123456789
5858
`
5959
err := os.WriteFile(configFile, []byte(content), 0644)
6060
if err != nil {
@@ -96,7 +96,7 @@ func TestRunStatus(t *testing.T) {
9696
"github.com",
9797
"Provider github",
9898
"User testuser (Test User)",
99-
"Token gho_****n123",
99+
"Token gho_******89",
100100
"Scopes repo, read:user",
101101
"Status ✓ Valid",
102102
},
@@ -107,7 +107,7 @@ func TestRunStatus(t *testing.T) {
107107
setupConfig: func(t *testing.T) string {
108108
tmpDir := t.TempDir()
109109
configFile := filepath.Join(tmpDir, "nix.conf")
110-
content := `access-tokens = github.com=gho_validtoken gitlab.com=glpat_invalidtoken
110+
content := `access-tokens = github.com=gho_validtoken123456 gitlab.com=glpat_invalidtoken789
111111
`
112112
err := os.WriteFile(configFile, []byte(content), 0644)
113113
if err != nil {
@@ -175,11 +175,11 @@ func TestRunStatus(t *testing.T) {
175175
"github.com",
176176
"Provider github",
177177
"User ghuser (GitHub User)",
178-
"Token gho_****oken",
178+
"Token gho_******56",
179179
"Status ✓ Valid",
180180
"gitlab.com",
181181
"Provider gitlab",
182-
"Token glpa****oken",
182+
"Token glpa********",
183183
"Status ✗ Invalid - 401 Unauthorized",
184184
},
185185
expectError: false,
@@ -189,7 +189,7 @@ func TestRunStatus(t *testing.T) {
189189
setupConfig: func(t *testing.T) string {
190190
tmpDir := t.TempDir()
191191
configFile := filepath.Join(tmpDir, "nix.conf")
192-
content := `access-tokens = unknown.host.com=token1234567890
192+
content := `access-tokens = unknown.host.com=token123456789012345
193193
`
194194
err := os.WriteFile(configFile, []byte(content), 0644)
195195
if err != nil {
@@ -205,7 +205,7 @@ func TestRunStatus(t *testing.T) {
205205
"unknown.host.com",
206206
"Provider unknown",
207207
"Status ⚠ Unknown (unverified)",
208-
"Token toke****7890",
208+
"Token toke********",
209209
"Scopes None",
210210
},
211211
expectError: false,

internal/ui/token.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
11
package ui
22

3-
import "fmt"
3+
import (
4+
"fmt"
5+
"strings"
6+
)
47

5-
// MaskToken masks a token for security, showing only the first and last 4 characters
8+
// MaskToken masks a token for security, showing only the token prefix for known types
69
func MaskToken(token string) string {
7-
if len(token) > 10 {
8-
return fmt.Sprintf("%s****%s", token[:4], token[len(token)-4:])
10+
// Handle empty or very short tokens
11+
if len(token) < 14 {
12+
return strings.Repeat("*", 8)
913
}
10-
return "Configured"
14+
15+
// Known token prefixes - these help identify the token type without revealing sensitive data
16+
knownPrefixes := []string{
17+
"gho_", // GitHub OAuth token
18+
"ghp_", // GitHub personal access token
19+
"ghs_", // GitHub server-to-server token
20+
"github_pat_", // GitHub fine-grained PAT
21+
"glpat-", // GitLab personal access token
22+
"gloas-", // GitLab OAuth access token
23+
"glrt-", // GitLab refresh token
24+
"gitea_", // Gitea token prefix (if standardized)
25+
}
26+
27+
// Check if token starts with a known prefix
28+
for _, prefix := range knownPrefixes {
29+
if strings.HasPrefix(token, prefix) {
30+
// Show prefix + last 2 chars for better differentiation between multiple tokens
31+
if len(token) >= len(prefix)+8 {
32+
return fmt.Sprintf("%s%s%s", prefix, strings.Repeat("*", 6), token[len(token)-2:])
33+
}
34+
// Fallback if token is too short
35+
return fmt.Sprintf("%s%s", prefix, strings.Repeat("*", 8))
36+
}
37+
}
38+
39+
// For unknown token types, show first 4 chars (might indicate type) + mask
40+
// This is more conservative than showing both prefix and suffix
41+
return fmt.Sprintf("%s%s", token[:4], strings.Repeat("*", 8))
1142
}

internal/ui/token_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package ui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestMaskToken(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
token string
12+
expected string
13+
}{
14+
// Empty and short tokens
15+
{
16+
name: "empty token",
17+
token: "",
18+
expected: "********",
19+
},
20+
{
21+
name: "very short token",
22+
token: "abc",
23+
expected: "********",
24+
},
25+
{
26+
name: "token exactly 15 chars",
27+
token: "123456789012345",
28+
expected: "1234********",
29+
},
30+
{
31+
name: "token exactly 16 chars",
32+
token: "1234567890123456",
33+
expected: "1234********",
34+
},
35+
36+
// GitHub tokens
37+
{
38+
name: "GitHub OAuth token",
39+
token: "gho_16C7e42F292c6912E7710c838347Ae178B4a",
40+
expected: "gho_******4a",
41+
},
42+
{
43+
name: "GitHub personal access token",
44+
token: "ghp_16C7e42F292c6912E7710c838347Ae178B4a",
45+
expected: "ghp_******4a",
46+
},
47+
{
48+
name: "GitHub server token",
49+
token: "ghs_16C7e42F292c6912E7710c838347Ae178B4a",
50+
expected: "ghs_******4a",
51+
},
52+
{
53+
name: "GitHub fine-grained PAT",
54+
token: "github_pat_11ABCDEF0_1234567890abcdef",
55+
expected: "github_pat_******ef",
56+
},
57+
58+
// GitLab tokens
59+
{
60+
name: "GitLab personal access token",
61+
token: "glpat-1234567890abcdefghij",
62+
expected: "glpat-******ij",
63+
},
64+
{
65+
name: "GitLab OAuth access token",
66+
token: "gloas-1234567890abcdefghij",
67+
expected: "gloas-******ij",
68+
},
69+
{
70+
name: "GitLab refresh token",
71+
token: "glrt-1234567890abcdefghij",
72+
expected: "glrt-******ij",
73+
},
74+
75+
// Gitea tokens
76+
{
77+
name: "Gitea token",
78+
token: "gitea_1234567890abcdefghij",
79+
expected: "gitea_******ij",
80+
},
81+
82+
// Unknown token types
83+
{
84+
name: "unknown token type",
85+
token: "xyz_1234567890abcdefghij",
86+
expected: "xyz_********",
87+
},
88+
{
89+
name: "random long token",
90+
token: "abcdefghijklmnopqrstuvwxyz1234567890",
91+
expected: "abcd********",
92+
},
93+
}
94+
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
result := MaskToken(tt.token)
98+
if result != tt.expected {
99+
t.Errorf("MaskToken(%q) = %q, want %q", tt.token, result, tt.expected)
100+
}
101+
102+
// Security check: ensure no sensitive part of the token is exposed
103+
if len(tt.token) >= 14 {
104+
// For known prefixes, we show last 2 chars
105+
hasKnownPrefix := false
106+
for _, prefix := range []string{"gho_", "ghp_", "ghs_", "github_pat_", "glpat-", "gloas-", "glrt-", "gitea_"} {
107+
if strings.HasPrefix(tt.token, prefix) {
108+
hasKnownPrefix = true
109+
break
110+
}
111+
}
112+
113+
if hasKnownPrefix && len(tt.token) > 8 {
114+
// Check we're not exposing more than last 2 chars
115+
suffix := tt.token[len(tt.token)-8 : len(tt.token)-2]
116+
if strings.Contains(result, suffix) {
117+
t.Errorf("MaskToken exposed too much of token suffix: %q contains %q", result, suffix)
118+
}
119+
} else if !hasKnownPrefix {
120+
// For unknown tokens, check we're not exposing any suffix
121+
suffix := tt.token[len(tt.token)-8:]
122+
if strings.Contains(result, suffix) {
123+
t.Errorf("MaskToken exposed token suffix: %q contains %q", result, suffix)
124+
}
125+
}
126+
127+
// Check that we're not exposing too much of the middle
128+
if len(tt.token) > 20 {
129+
middle := tt.token[10:20]
130+
if strings.Contains(result, middle) {
131+
t.Errorf("MaskToken exposed token middle: %q contains %q", result, middle)
132+
}
133+
}
134+
}
135+
})
136+
}
137+
}
138+
139+
func TestMaskTokenSecurity(t *testing.T) {
140+
// Test that the function handles Unicode correctly
141+
t.Run("unicode token", func(t *testing.T) {
142+
token := "test_こんにちは世界1234567890"
143+
result := MaskToken(token)
144+
// Should show first 4 bytes, not break Unicode
145+
if result != "test********" {
146+
t.Errorf("MaskToken failed to handle Unicode correctly: got %q", result)
147+
}
148+
})
149+
150+
// Test consistent masking length
151+
t.Run("consistent mask length", func(t *testing.T) {
152+
tokens := []struct {
153+
token string
154+
expected string
155+
}{
156+
{"gho_shorttoken123", "gho_******23"},
157+
{"gho_verylongtokenwithmanymorecharacters123456789", "gho_******89"},
158+
}
159+
for _, tt := range tokens {
160+
result := MaskToken(tt.token)
161+
if result != tt.expected {
162+
t.Errorf("MaskToken inconsistent masking for %q: got %q, want %q", tt.token, result, tt.expected)
163+
}
164+
}
165+
})
166+
}

0 commit comments

Comments
 (0)