detached cbb8ad6

Merge pull request 'fix(executor): mask base64 of a secret followed by other bytes' (#8) from fix/mask-base64-tail into main

commit
cbb8ad605a21ab39fd7a02ba2e5b3f54eb3da368
author
bnema · · 2026-10-13 04:42 UTC
merge of
fc8849e + e66e40e · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.1.0
2 files · +54 -4
M internal/executor/masker.go +19 -4
@@ -17,10 +17,21 @@ import (
1717 // base64 at the three byte offsets a prefix of one or two bytes can give
1818 // ("user:" + secret is base64-encoded as a whole), and its JSON and URI
1919 // escapes. Longer forms are replaced first.
20+//
21+// Bytes that follow the secret change the last, incomplete group of its base64
22+// (`echo "$T" | base64` appends a newline), so each base64 form is also
23+// registered without that group: only the characters the secret alone
24+// determines. actions/runner (ValueEncoders.Base64StringEscapeShift) only
25+// registers the full forms; the truncated ones are an addition.
2026 type masker struct{ values []string }
2127
2228 const maskedValue = "***"
2329
30+// minBase64Tail is the shortest base64 form, in characters, registered once its
31+// incomplete last group is removed: a shorter one (3 bytes of the secret)
32+// would mask ordinary text.
33+const minBase64Tail = 8
34+
2435 func newMasker(secrets ...string) *masker {
2536 seen := map[string]bool{}
2637 var vals []string
@@ -45,10 +56,14 @@ func secretEncodings(v string) []string {
4556 var out []string
4657 b := []byte(v)
4758 for shift := range 3 {
48- if len(b) > shift {
49- out = append(out, base64.StdEncoding.EncodeToString(b[shift:]))
50- } else if shift == 0 {
51- out = append(out, base64.StdEncoding.EncodeToString(b))
59+ if len(b) <= shift && shift != 0 {
60+ continue
61+ }
62+ sub := b[shift:]
63+ out = append(out, base64.StdEncoding.EncodeToString(sub))
64+ // Whole groups only: 3 bytes give 4 characters that no later byte changes.
65+ if n := len(sub) / 3 * 4; n >= minBase64Tail {
66+ out = append(out, base64.StdEncoding.EncodeToString(sub[:len(sub)/3*3]))
5267 }
5368 }
5469 if j, err := json.Marshal(v); err == nil && len(j) > 2 {
@@ -17,10 +17,21 @@ import (
17 // base64 at the three byte offsets a prefix of one or two bytes can give17 // base64 at the three byte offsets a prefix of one or two bytes can give
18 // ("user:" + secret is base64-encoded as a whole), and its JSON and URI18 // ("user:" + secret is base64-encoded as a whole), and its JSON and URI
19 // escapes. Longer forms are replaced first.19 // escapes. Longer forms are replaced first.
20+//
21+// Bytes that follow the secret change the last, incomplete group of its base64
22+// (`echo "$T" | base64` appends a newline), so each base64 form is also
23+// registered without that group: only the characters the secret alone
24+// determines. actions/runner (ValueEncoders.Base64StringEscapeShift) only
25+// registers the full forms; the truncated ones are an addition.
20 type masker struct{ values []string }26 type masker struct{ values []string }
21 27
22 const maskedValue = "***"28 const maskedValue = "***"
23 29
30+// minBase64Tail is the shortest base64 form, in characters, registered once its
31+// incomplete last group is removed: a shorter one (3 bytes of the secret)
32+// would mask ordinary text.
33+const minBase64Tail = 8
34+
24 func newMasker(secrets ...string) *masker {35 func newMasker(secrets ...string) *masker {
25 seen := map[string]bool{}36 seen := map[string]bool{}
26 var vals []string37 var vals []string
@@ -45,10 +56,14 @@ func secretEncodings(v string) []string {
45 var out []string56 var out []string
46 b := []byte(v)57 b := []byte(v)
47 for shift := range 3 {58 for shift := range 3 {
48- if len(b) > shift {59+ if len(b) <= shift && shift != 0 {
49- out = append(out, base64.StdEncoding.EncodeToString(b[shift:]))60+ continue
50- } else if shift == 0 {61+ }
51- out = append(out, base64.StdEncoding.EncodeToString(b))62+ sub := b[shift:]
63+ out = append(out, base64.StdEncoding.EncodeToString(sub))
64+ // Whole groups only: 3 bytes give 4 characters that no later byte changes.
65+ if n := len(sub) / 3 * 4; n >= minBase64Tail {
66+ out = append(out, base64.StdEncoding.EncodeToString(sub[:len(sub)/3*3]))
52 }67 }
53 }68 }
54 if j, err := json.Marshal(v); err == nil && len(j) > 2 {69 if j, err := json.Marshal(v); err == nil && len(j) > 2 {
M internal/executor/masker_test.go +35 -0
@@ -56,6 +56,41 @@ func TestMaskerForms(t *testing.T) {
5656 }
5757 }
5858
59+// Bytes after the secret change the end of its base64 (`echo "$T" | base64`
60+// appends a newline): the part the secret alone determines is still masked, at
61+// the three offsets and whatever the length of the secret.
62+func TestMaskerBase64FollowedByOtherBytes(t *testing.T) {
63+ for _, secret := range []string{testToken, testToken + "4", testToken + "45", "ghs_0123456789abcdef"} {
64+ m := newMasker(secret)
65+ for shift := 0; shift < 3; shift++ {
66+ prefix := strings.Repeat("p", shift)
67+ for _, suffix := range []string{"\n", "!", "xy", " and more text"} {
68+ enc := base64.StdEncoding.EncodeToString([]byte(prefix + secret + suffix))
69+ text := "out: " + enc + " :end"
70+ got := m.mask(text)
71+ if !m.has(text) || got == text || !strings.Contains(got, "***") {
72+ t.Errorf("secret %q, prefix %q, suffix %q: %q -> %q", secret, prefix, suffix, text, got)
73+ continue
74+ }
75+ // the groups the secret alone determines are gone
76+ if strings.Contains(got, enc[4:12]) {
77+ t.Errorf("secret %q, prefix %q, suffix %q: %q still has the secret", secret, prefix, suffix, got)
78+ }
79+ }
80+ }
81+ }
82+ // the exact case of the review
83+ m := newMasker(testToken)
84+ if got := m.mask("Z2hzX3Rlc3R0b2tlbjEyMwo="); got == "Z2hzX3Rlc3R0b2tlbjEyMwo=" {
85+ t.Errorf("echo $T | base64 is not masked: %q", got)
86+ }
87+ // a short secret does not mask the base64 of a few common bytes
88+ short := newMasker("abcd")
89+ if got := short.mask("YWJj"); got != "YWJj" {
90+ t.Errorf("a short tail is masked: %q", got)
91+ }
92+}
93+
5994 // The name of a step that holds the token is masked before it reaches the
6095 // server (StepStarted and StepCompleted).
6196 func TestStepNameIsMasked(t *testing.T) {
@@ -56,6 +56,41 @@ func TestMaskerForms(t *testing.T) {
56 }56 }
57 }57 }
58 58
59+// Bytes after the secret change the end of its base64 (`echo "$T" | base64`
60+// appends a newline): the part the secret alone determines is still masked, at
61+// the three offsets and whatever the length of the secret.
62+func TestMaskerBase64FollowedByOtherBytes(t *testing.T) {
63+ for _, secret := range []string{testToken, testToken + "4", testToken + "45", "ghs_0123456789abcdef"} {
64+ m := newMasker(secret)
65+ for shift := 0; shift < 3; shift++ {
66+ prefix := strings.Repeat("p", shift)
67+ for _, suffix := range []string{"\n", "!", "xy", " and more text"} {
68+ enc := base64.StdEncoding.EncodeToString([]byte(prefix + secret + suffix))
69+ text := "out: " + enc + " :end"
70+ got := m.mask(text)
71+ if !m.has(text) || got == text || !strings.Contains(got, "***") {
72+ t.Errorf("secret %q, prefix %q, suffix %q: %q -> %q", secret, prefix, suffix, text, got)
73+ continue
74+ }
75+ // the groups the secret alone determines are gone
76+ if strings.Contains(got, enc[4:12]) {
77+ t.Errorf("secret %q, prefix %q, suffix %q: %q still has the secret", secret, prefix, suffix, got)
78+ }
79+ }
80+ }
81+ }
82+ // the exact case of the review
83+ m := newMasker(testToken)
84+ if got := m.mask("Z2hzX3Rlc3R0b2tlbjEyMwo="); got == "Z2hzX3Rlc3R0b2tlbjEyMwo=" {
85+ t.Errorf("echo $T | base64 is not masked: %q", got)
86+ }
87+ // a short secret does not mask the base64 of a few common bytes
88+ short := newMasker("abcd")
89+ if got := short.mask("YWJj"); got != "YWJj" {
90+ t.Errorf("a short tail is masked: %q", got)
91+ }
92+}
93+
59 // The name of a step that holds the token is masked before it reaches the94 // The name of a step that holds the token is masked before it reaches the
60 // server (StepStarted and StepCompleted).95 // server (StepStarted and StepCompleted).
61 func TestStepNameIsMasked(t *testing.T) {96 func TestStepNameIsMasked(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close