feat: mask secrets that reach the log in an encoded form (#1108)

Only the verbatim value of a secret was masked, so a secret leaked through an action that serialized it stayed readable: `toJSON(secrets)` escapes it, an Authorization header carries it base64-encoded, a URL percent-encodes it. Each secret and `::add-mask::` value is now masked in those forms too, matching the value encoders of GitHub's runner. Encodings that leave the value unchanged are skipped, so a plain token still costs a single replacement pair. Includes regression tests.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1108
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
This commit is contained in:
bircni
2026-07-31 12:35:10 +00:00
parent 0cd0e52a24
commit 68c6a5b4f1
3 changed files with 275 additions and 8 deletions

View File

@@ -7,8 +7,11 @@ package runner
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"slices"
"strings"
@@ -167,6 +170,76 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
// secretValueEncoders are the shapes a secret takes on its way into a log: a base64
// payload, a JSON string, or a URL component. An action that serializes a secret leaks
// it in one of these forms, which a mask of the verbatim value alone does not catch, so
// every form is masked as well. This mirrors the value encoders of GitHub's runner.
var secretValueEncoders = []func(string) string{
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
base64ShiftEncoder(1),
base64ShiftEncoder(2),
jsonStringEscape,
jsonStringEscapeNoHTML,
url.QueryEscape,
url.PathEscape,
}
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter
// one carries too few bytes of the secret to identify it and would mask unrelated output.
const minShiftedBase64Len = 8
// base64ShiftEncoder returns the part of a secret's base64 form that survives when the
// secret does not start on a 3-byte boundary of the payload it is embedded in. base64
// encodes three bytes at a time, so `Authorization: Basic base64("user:token")` contains
// the base64 of the token alone only when the prefix length happens to be a multiple of
// three; at the other two alignments the encoding of the whole value differs. Encoding
// the secret behind shift filler bytes reproduces those alignments, which is what the
// Base64StringEscapeShift1/2 encoders of GitHub's runner do.
//
// The leading group (filler mixed with the secret's first bytes) and the trailing group
// (padded here, but continuing into whatever follows the secret) are dropped, leaving the
// group-aligned middle that does appear verbatim in the log.
func base64ShiftEncoder(shift int) func(string) string {
return func(v string) string {
buf := make([]byte, shift+len(v))
copy(buf[shift:], v)
encoded := base64.StdEncoding.EncodeToString(buf)
// Keep only the aligned middle, and only when enough of it is left to be a
// distinctive pattern rather than a fragment that matches unrelated output.
if len(encoded) < 8+minShiftedBase64Len {
return ""
}
return encoded[4 : len(encoded)-4]
}
}
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
// that do not. When v has none of those characters both forms are equal and deduplicated.
func jsonStringEscape(v string) string {
encoded, err := json.Marshal(v)
if err != nil {
return v
}
return string(encoded[1 : len(encoded)-1])
}
// jsonStringEscapeNoHTML is jsonStringEscape without HTML escaping, matching the JSON a
// JavaScript (JSON.stringify) or .NET action emits, so a secret containing < > or & is
// masked in that form too.
func jsonStringEscapeNoHTML(v string) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return v
}
// Encode appends a newline; drop it along with the surrounding quotes.
encoded := strings.TrimRight(buf.String(), "\n")
return encoded[1 : len(encoded)-1]
}
func AppendSecretMasker(oldnew []string, v string) []string {
ret := oldnew
@@ -182,6 +255,21 @@ func AppendSecretMasker(oldnew []string, v string) []string {
}
}
// The encoded forms are derived from the whole value: a multi-line secret is
// encoded as one string, not line by line.
trimmed := strings.TrimSpace(v)
if len(trimmed) <= 1 {
return ret
}
for _, encode := range secretValueEncoders {
encoded := encode(trimmed)
// An encoding that leaves the value unchanged is already masked above.
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) {
continue
}
ret = append(ret, encoded, "***")
}
return ret
}
@@ -194,6 +282,18 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
}
oldnew = slices.Clip(oldnew)
defReplacer := strings.NewReplacer(oldnew...)
// A ::add-mask:: only ever appends to the job's mask slice, so the replacer built for
// it stays valid until the slice grows. Cache it, keyed by the slice itself and its
// length, instead of encoding every secret and mask again for each log line.
var (
mu sync.Mutex
masksRef *[]string
pairs []string
masked int
replacer *strings.Replacer
)
return func(entry *logrus.Entry) *logrus.Entry {
if insecureSecrets {
return entry
@@ -203,16 +303,27 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
if len(*masks) == 0 {
entry.Message = defReplacer.Replace(entry.Message)
} else {
cmasker := oldnew
for _, v := range *masks {
cmasker = AppendSecretMasker(cmasker, v)
}
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
return entry
}
mu.Lock()
// A composite action logs through the same masker with its own mask slice, so a
// different slice starts the cache over.
if masksRef != masks {
masksRef, pairs, masked, replacer = masks, oldnew, 0, nil
}
if replacer == nil || masked != len(*masks) {
for _, v := range (*masks)[masked:] {
pairs = AppendSecretMasker(pairs, v)
}
masked = len(*masks)
replacer = strings.NewReplacer(pairs...)
}
cmasker := replacer
mu.Unlock()
entry.Message = cmasker.Replace(entry.Message)
return entry
}
}

View File

@@ -4,7 +4,9 @@
package runner
import (
"encoding/base64"
"io"
"net/url"
"strings"
"testing"
@@ -59,6 +61,136 @@ func TestValueMasker(t *testing.T) {
}
}
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
// URL — must be masked as well: masking only the verbatim value leaks it.
func TestValueMaskerEncodedSecrets(t *testing.T) {
secret := `p@ss w"rd/1`
masker := valueMasker(false, map[string]string{"TOKEN": secret})
for _, tc := range []struct {
name string
line string
}{
{"verbatim", "the token is " + secret},
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))},
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`},
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)},
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"},
} {
t.Run(tc.name, func(t *testing.T) {
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line})
assert.Contains(t, entry.Message, "***")
assert.NotContains(t, entry.Message, secret)
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret)))
assert.NotContains(t, entry.Message, url.QueryEscape(secret))
})
}
}
// A secret containing " together with <, > or & serializes to JSON differently depending
// on the runtime: act's own toJSON (and Go) HTML-escape <>&, while a JavaScript
// (JSON.stringify) or .NET action leaves them literal. The secret must be masked in either
// form, so a JS-serialized JSON body does not leak it.
func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
secret := `a"<b>&c`
masker := valueMasker(false, map[string]string{"TOKEN": secret})
for _, tc := range []struct {
name string
form string
}{
{"html escaped (act toJSON / Go)", jsonStringEscape(secret)},
{"literal (JS JSON.stringify / .NET)", jsonStringEscapeNoHTML(secret)},
} {
t.Run(tc.name, func(t *testing.T) {
entry := masker(&logrus.Entry{Context: t.Context(), Message: `{"t":"` + tc.form + `"}`})
assert.Contains(t, entry.Message, "***")
assert.NotContains(t, entry.Message, tc.form)
})
}
}
// ::add-mask:: values go through the same masker, so they get the same treatment.
func TestValueMaskerEncodedMasks(t *testing.T) {
masks := []string{"s3cr3t value"}
masker := valueMasker(false, nil)
entry := masker(&logrus.Entry{
Context: WithMasks(t.Context(), &masks),
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")),
})
assert.Equal(t, "encoded: ***", entry.Message)
}
// A token in a Basic auth header is base64'd together with the user name, so the token's
// own base64 only appears when the prefix length is a multiple of three. The other two
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
// the token to anyone who can decode the log.
func TestValueMaskerBase64Alignments(t *testing.T) {
secret := "s3cr3t-token-value"
masker := valueMasker(false, map[string]string{"TOKEN": secret})
// One prefix per alignment: len%3 of 0, 1 and 2.
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
t.Run(prefix, func(t *testing.T) {
encoded := base64.StdEncoding.EncodeToString([]byte(prefix + secret))
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
assert.Contains(t, entry.Message, "***")
// The aligned middle of the secret must be gone, so the payload can no longer be
// decoded back into the token.
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
decoded, err := base64.StdEncoding.DecodeString(decodable)
if err == nil {
assert.NotContains(t, string(decoded), secret)
}
})
}
}
// The masker caches its replacer, so it has to notice both a mask appended to the same
// slice and a composite action logging with a slice of its own.
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
masker := valueMasker(false, map[string]string{"TOKEN": "secret-token"})
mask := func(masks *[]string, message string) string {
return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message
}
job := []string{"first mask"}
assert.Equal(t, "a *** and ***", mask(&job, "a first mask and secret-token"))
// ::add-mask:: appends to the same slice
job = append(job, "second mask")
assert.Equal(t, "*** and ***", mask(&job, "first mask and second mask"))
// a composite action brings its own slice
composite := []string{"composite mask"}
assert.Equal(t, "*** but first mask", mask(&composite, "composite mask but first mask"))
// and the job's masks still apply once it is back
assert.Equal(t, "*** and *** but composite mask", mask(&job, "first mask and second mask but composite mask"))
}
func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
// A token with no character an escape would touch only gains its base64 forms:
// JSON, query and path escaping all leave it unchanged.
pairs := AppendSecretMasker(nil, "plaintoken")
assert.Equal(t, []string{
"plaintoken", "***",
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***",
// The two shifted alignments, each without its leading and trailing group.
"YWludG9r", "***",
"bGFpbnRv", "***",
}, pairs)
// Too short to mask.
assert.Empty(t, AppendSecretMasker(nil, "x"))
}
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
logger := logrus.New()
logger.Out = io.Discard

View File

@@ -5,15 +5,18 @@ package report
import (
"context"
"encoding/base64"
"errors"
"fmt"
"maps"
"net/url"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.com/gitea/runner/act/runner"
"gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
@@ -1096,3 +1099,24 @@ func TestReporter_ParseResult(t *testing.T) {
})
}
}
// A secret leaked in an encoded form — the shape it takes once an action puts it in a
// JSON body, a URL or a base64 payload — must be masked in the reported log as well.
func TestReporter_masksEncodedSecrets(t *testing.T) {
secret := `p@ss w"rd/1`
r := &Reporter{logReplacer: strings.NewReplacer()}
r.oldnew = runner.AppendSecretMasker(r.oldnew, secret)
r.logReplacer = strings.NewReplacer(r.oldnew...)
for _, line := range []string{
"token: " + secret,
"basic " + base64.StdEncoding.EncodeToString([]byte(secret)),
"https://example.com/?token=" + url.QueryEscape(secret),
} {
row := r.parseLogRow(&log.Entry{Message: line})
require.NotNil(t, row)
assert.Contains(t, row.Content, "***")
assert.NotContains(t, row.Content, secret)
assert.NotContains(t, row.Content, base64.StdEncoding.EncodeToString([]byte(secret)))
}
}