Files
Runner/act/runner/logger_test.go
T
silverwind 0712b2a7a1 fix: mask secrets on every path they leave a job (#1188)
A secret in a matrix value reached the log in the clear:

```yaml
strategy:
  matrix:
    include: "${{ github.token }}"
```

Chasing that one route is pointless, so this masks every sink a secret leaves a job by: the uploaded log rows and the on-disk `job.log`, both through one choke point in `appendLogRow`; the runner's own log, which is where planning errors like that one land with no job logger in reach; the job logger's stdout under debug logging; job summaries; job outputs; and the job name that becomes a container name.

Values the runner knows but the job never declared, the proxy password and the task token, are hidden the same way. Masks apply longest first, since `strings.Replacer` matches in argument order and one secret prefixing another would otherwise mask the prefix and print the rest.

### What changes for users

An output whose value carries a secret is skipped with a warning instead of sent, matching GitHub. Output that showed a secret now shows `***`. `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG` are never masked, also matching GitHub, so an output of `true` still reaches the jobs that need it.

Each fix has a test that fails without it.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1188
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-25 20:36:05 +00:00

217 lines
8.6 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"encoding/base64"
"io"
"net/url"
"strings"
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValueMasker(t *testing.T) {
table := []struct {
name string
lines string
secrets map[string]string
masks []string
disallowed []string
}{
{
name: "Multiline Private Key",
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
secrets: map[string]string{
"PRIVATE_KEY": "PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END",
},
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
},
{
name: "Multiline Private Key in masks",
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
},
{
name: "Secret containing a percent sign",
lines: "##[error]login failed for pass%25word",
secrets: map[string]string{"TOKEN": "pass%word"},
disallowed: []string{"pass%25word"},
},
}
for _, entry := range table {
t.Run(entry.name, func(t *testing.T) {
ctx := WithMasks(t.Context(), &entry.masks)
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
for line := range strings.SplitSeq(entry.lines, "\n") {
lentry := masker(&logrus.Entry{
Context: ctx,
Message: line,
})
for _, line := range entry.disallowed {
assert.NotContains(t, lentry.Message, line)
}
}
})
}
}
// 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, AppendSecretMaskers(nil, 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, AppendSecretMaskers(nil, 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)
})
}
}
// With debug logging on, the job logger writes to stdout, which no reporter masks, so it has
// to hide the values that are not job secrets too.
func TestValueMaskerHidesExtraMasks(t *testing.T) {
masker := valueMasker(false, (&Config{ExtraMasks: []string{"pr0xypw"}}).maskers())
entry := masker(&logrus.Entry{Context: t.Context(), Message: "proxy is http://user:pr0xypw@proxy:3128"})
assert.Equal(t, "proxy is http://user:***@proxy:3128", entry.Message)
}
// ::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, AppendSecretMaskers(nil, 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, AppendSecretMaskers(nil, 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, AppendSecretMaskers(nil, 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
format := func(message string) string {
out, err := (&jobLogFormatter{}).Format(&logrus.Entry{Logger: logger, Message: message, Data: logrus.Fields{rawOutputField: true}})
require.NoError(t, err)
return string(out)
}
assert.Contains(t, format("##[error]deploy 50%25 traffic"), "##[error]deploy 50% traffic")
// a plain line is not command data and keeps its literal escapes
assert.Contains(t, format("progress 50%25 done"), "progress 50%25 done")
}