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

@@ -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)))
}
}