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>
This commit is contained in:
silverwind
2026-08-25 20:36:05 +00:00
committed by silverwind
parent 34df4887af
commit 0712b2a7a1
16 changed files with 377 additions and 56 deletions
+4
View File
@@ -390,6 +390,10 @@ See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environm
Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change. Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change.
#### Secret masking
A job's secrets and its `::add-mask::` values are hidden from what the runner writes and uploads: the job log, the local copy above, job summaries, and the names of the containers it creates. A job output carrying one is skipped with a warning rather than sent masked, as GitHub does, so a downstream `needs.<job>.outputs.<name>` reading it is empty.
`log.job.retention` (default `168h`) is how long a log is kept, expired ones being deleted as new tasks start, and `log.job.max_size` (default `1GB`) caps one log. Keep `retention` above `runner.timeout` so a long job cannot outlive its own log, and prefer local disk, the file is written while the job runs. Only the runner's own user can read it. `log.job.retention` (default `168h`) is how long a log is kept, expired ones being deleted as new tasks start, and `log.job.max_size` (default `1GB`) caps one log. Keep `retention` above `runner.timeout` so a long job cannot outlive its own log, and prefer local disk, the file is written while the job runs. Only the runner's own user can read it.
### Example Deployments ### Example Deployments
+22
View File
@@ -356,3 +356,25 @@ on:
} }
} }
} }
func TestJobNameMasksSecrets(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`
jobs:
a:
name: deploy ${{ secrets.A }}
b:
name: deploy ${{ secrets.B }}
`))
require.NoError(t, err)
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
containerName := func(jobID string) string {
rc := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
assert.NotContains(t, rc.Name, "s3cr3t")
return rc.jobContainerName()
}
a, b := containerName("a"), containerName("b")
assert.NotContains(t, a, "s3cr3t") // it reaches the container name, which no log masker covers
assert.NotEqual(t, a, b) // masking the name must not collapse two jobs onto one container
}
+3 -2
View File
@@ -388,7 +388,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
if rc.caller != nil { if rc.caller != nil {
// set reusable workflow job result // set reusable workflow job result
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, jobResult) // For Gitea
return return
} }
@@ -487,7 +487,8 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
if !ok || len(body) == 0 { if !ok || len(body) == 0 {
continue continue
} }
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, body) // Gitea renders summaries on the run page, so mask before the upload.
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, []byte(rc.maskSecrets(string(body))))
} }
} }
+34
View File
@@ -1096,3 +1096,37 @@ func TestJobSetContinueOnError(t *testing.T) {
assert.True(t, j.ContinueOnError) assert.True(t, j.ContinueOnError)
}) })
} }
func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
var got string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
got = string(body)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
cm := &containerMock{}
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{
name: "step-summary-0.md", body: "deployed true with s3cr3t and runtime-added via pr0xypw",
}))),
nil,
).Once()
rc := newJobSummaryRC(map[string]string{
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
"ACTIONS_RUNTIME_URL": server.URL,
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34),
"GITEA_RUN_ID": "12",
}, cm, 1)
rc.Config.Secrets = map[string]string{"TOK": "s3cr3t", "ACTIONS_STEP_DEBUG": "true"}
rc.Config.ExtraMasks = []string{"pr0xypw"}
rc.Masks = []string{"runtime-added"}
tryUploadJobSummary(context.Background(), rc)
assert.Equal(t, "deployed true with *** and *** via ***", got)
cm.AssertExpectations(t)
}
+51 -8
View File
@@ -122,7 +122,7 @@ func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, m
logger.SetFormatter(&maskedFormatter{ logger.SetFormatter(&maskedFormatter{
Formatter: logger.Formatter, Formatter: logger.Formatter,
masker: valueMasker(config.InsecureSecrets, config.Secrets), masker: valueMasker(config.InsecureSecrets, config.maskers()),
}) })
rtn := logger.WithFields(logrus.Fields{ rtn := logger.WithFields(logrus.Fields{
"job": jobName, "job": jobName,
@@ -234,6 +234,17 @@ func jsonStringEscapeNoHTML(v string) string {
return string(encoded[1 : len(encoded)-1]) return string(encoded[1 : len(encoded)-1])
} }
// AppendSecretMaskers skips the debug settings, as GitHub does: they arrive as secrets, but
// masking "true" would corrupt unrelated log lines and drop job outputs that say it.
func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
for k, v := range secrets {
if k != "ACTIONS_STEP_DEBUG" && k != "ACTIONS_RUNNER_DEBUG" {
oldnew = AppendSecretMasker(oldnew, v)
}
}
return oldnew
}
func AppendSecretMasker(oldnew []string, v string) []string { func AppendSecretMasker(oldnew []string, v string) []string {
ret := oldnew ret := oldnew
@@ -269,13 +280,9 @@ func AppendSecretMasker(oldnew []string, v string) []string {
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including // valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
// raw_output (command/stream) lines; there is no bypass by field. // raw_output (command/stream) lines; there is no bypass by field.
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor { func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
var oldnew []string
for _, v := range secrets {
oldnew = AppendSecretMasker(oldnew, v)
}
oldnew = slices.Clip(oldnew) oldnew = slices.Clip(oldnew)
defReplacer := strings.NewReplacer(oldnew...) defReplacer := NewSecretReplacer(oldnew)
// A ::add-mask:: only ever appends to the job's mask slice, so the replacer built for // 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 // it stays valid until the slice grows. Cache it, keyed by the slice itself and its
@@ -311,7 +318,7 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
pairs = AppendSecretMasker(pairs, v) pairs = AppendSecretMasker(pairs, v)
} }
masked = len(*masks) masked = len(*masks)
replacer = strings.NewReplacer(pairs...) replacer = NewSecretReplacer(pairs)
} }
cmasker := replacer cmasker := replacer
mu.Unlock() mu.Unlock()
@@ -419,3 +426,39 @@ func checkIfTerminal(w io.Writer) bool {
return false return false
} }
} }
// maskSecrets hides this job's secrets in a value that reaches somewhere the log maskers cannot,
// such as a container name or a job summary. Masks added at runtime count, so a summary written
// after ::add-mask:: is covered too.
func (rc *RunContext) maskSecrets(value string) string {
oldnew := rc.Config.maskers()
for _, mask := range rc.Masks {
oldnew = AppendSecretMasker(oldnew, mask)
}
return NewSecretReplacer(oldnew).Replace(value)
}
// maskers is every value this job's configuration says to hide, whatever the sink.
func (c *Config) maskers() []string {
oldnew := AppendSecretMaskers(nil, c.Secrets)
for _, mask := range c.ExtraMasks {
oldnew = AppendSecretMasker(oldnew, mask)
}
return oldnew
}
// NewSecretReplacer masks the longest secret first. Replacer matches in argument order, so a
// secret that prefixes another would otherwise mask only that prefix and print the rest.
func NewSecretReplacer(oldnew []string) *strings.Replacer {
pairs := make([][2]string, 0, len(oldnew)/2)
for i := 0; i+1 < len(oldnew); i += 2 {
pairs = append(pairs, [2]string{oldnew[i], oldnew[i+1]})
}
slices.SortFunc(pairs, func(a, b [2]string) int { return len(b[0]) - len(a[0]) })
sorted := make([]string, 0, len(pairs)*2)
for _, pair := range pairs {
sorted = append(sorted, pair[0], pair[1])
}
return strings.NewReplacer(sorted...)
}
+16 -6
View File
@@ -47,7 +47,7 @@ func TestValueMasker(t *testing.T) {
for _, entry := range table { for _, entry := range table {
t.Run(entry.name, func(t *testing.T) { t.Run(entry.name, func(t *testing.T) {
ctx := WithMasks(t.Context(), &entry.masks) ctx := WithMasks(t.Context(), &entry.masks)
masker := valueMasker(false, entry.secrets) masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
for line := range strings.SplitSeq(entry.lines, "\n") { for line := range strings.SplitSeq(entry.lines, "\n") {
lentry := masker(&logrus.Entry{ lentry := masker(&logrus.Entry{
Context: ctx, Context: ctx,
@@ -65,7 +65,7 @@ func TestValueMasker(t *testing.T) {
// URL — must be masked as well: masking only the verbatim value leaks it. // URL — must be masked as well: masking only the verbatim value leaks it.
func TestValueMaskerEncodedSecrets(t *testing.T) { func TestValueMaskerEncodedSecrets(t *testing.T) {
secret := `p@ss w"rd/1` secret := `p@ss w"rd/1`
masker := valueMasker(false, map[string]string{"TOKEN": secret}) masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -94,7 +94,7 @@ func TestValueMaskerEncodedSecrets(t *testing.T) {
// form, so a JS-serialized JSON body does not leak it. // form, so a JS-serialized JSON body does not leak it.
func TestValueMaskerJSONEscapesBothWays(t *testing.T) { func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
secret := `a"<b>&c` secret := `a"<b>&c`
masker := valueMasker(false, map[string]string{"TOKEN": secret}) masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -112,10 +112,20 @@ func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
} }
} }
// 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. // ::add-mask:: values go through the same masker, so they get the same treatment.
func TestValueMaskerEncodedMasks(t *testing.T) { func TestValueMaskerEncodedMasks(t *testing.T) {
masks := []string{"s3cr3t value"} masks := []string{"s3cr3t value"}
masker := valueMasker(false, nil) masker := valueMasker(false, AppendSecretMaskers(nil, nil))
entry := masker(&logrus.Entry{ entry := masker(&logrus.Entry{
Context: WithMasks(t.Context(), &masks), Context: WithMasks(t.Context(), &masks),
@@ -131,7 +141,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
// the token to anyone who can decode the log. // the token to anyone who can decode the log.
func TestValueMaskerBase64Alignments(t *testing.T) { func TestValueMaskerBase64Alignments(t *testing.T) {
secret := "s3cr3t-token-value" secret := "s3cr3t-token-value"
masker := valueMasker(false, map[string]string{"TOKEN": secret}) masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
// One prefix per alignment: len%3 of 0, 1 and 2. // One prefix per alignment: len%3 of 0, 1 and 2.
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} { for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
@@ -155,7 +165,7 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
// The masker caches its replacer, so it has to notice both a mask appended to the same // 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. // slice and a composite action logging with a slice of its own.
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) { func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
masker := valueMasker(false, map[string]string{"TOKEN": "secret-token"}) masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": "secret-token"}))
mask := func(masks *[]string, message string) string { mask := func(masks *[]string, message string) string {
return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message
} }
+1 -1
View File
@@ -236,7 +236,7 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
} }
if rc.caller != nil { if rc.caller != nil {
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult) rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult)
} else { } else {
// Serialize this shared Job.Result write against the other matrix combos // Serialize this shared Job.Result write against the other matrix combos
// and setJobResult (same lockJob key). // and setJobResult (same lockJob key).
+4 -3
View File
@@ -180,7 +180,8 @@ func (rc *RunContext) GetEnv() map[string]string {
} }
func (rc *RunContext) jobContainerName() string { func (rc *RunContext) jobContainerName() string {
nameParts := []string{rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Name} // The job id, never evaluated, keeps two jobs apart when masking collapses their names.
nameParts := []string{rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Run.JobID, rc.Name}
if rc.caller != nil { if rc.caller != nil {
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName) nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
} }
@@ -1026,7 +1027,7 @@ func (rc *RunContext) Executor() (common.Executor, error) {
// unfinished. rc.caller is only set for reusable workflows. // unfinished. rc.caller is only set for reusable workflows.
rc.result("failure") rc.result("failure")
if rc.caller != nil { // For Gitea if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure") rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "failure")
} }
return err return err
} }
@@ -1116,7 +1117,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
if !runJob { if !runJob {
if rc.caller != nil { // For Gitea if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.JobName, "skipped") rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "skipped")
return false, nil return false, nil
} }
l.WithField("jobResult", "skipped").Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value) l.WithField("jobResult", "skipped").Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value)
+6 -4
View File
@@ -36,6 +36,7 @@ type Config struct {
JSONLogger bool // use json or text logger JSONLogger bool // use json or text logger
Env map[string]string // env for containers Env map[string]string // env for containers
Secrets map[string]string // list of secrets Secrets map[string]string // list of secrets
ExtraMasks []string // values to hide that are not in Secrets, such as the proxy password
Vars map[string]string // list of vars Vars map[string]string // list of vars
Token string // GitHub token Token string // GitHub token
InsecureSecrets bool // switch hiding output when printing to terminal InsecureSecrets bool // switch hiding output when printing to terminal
@@ -238,7 +239,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
maxJobNameLen = len(rc.String()) maxJobNameLen = len(rc.String())
} }
if rc.caller != nil { // For Gitea if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.JobName, "pending") rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "pending")
} }
stageExecutor = append(stageExecutor, func(ctx context.Context) error { stageExecutor = append(stageExecutor, func(ctx context.Context) error {
jobName := fmt.Sprintf("%-*s", maxJobNameLen, rc.String()) jobName := fmt.Sprintf("%-*s", maxJobNameLen, rc.String())
@@ -324,7 +325,7 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
caller: runner.caller, caller: runner.caller,
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.ExprEval.Interpolate(ctx, run.String()) rc.Name = rc.maskSecrets(rc.ExprEval.Interpolate(ctx, run.String()))
// Snapshot the job's pristine output expressions now, before any matrix combo runs and // Snapshot the job's pristine output expressions now, before any matrix combo runs and
// rewrites the shared Job.Outputs (see interpolateOutputs). // rewrites the shared Job.Outputs (see interpolateOutputs).
if job := run.Job(); job != nil { if job := run.Job(); job != nil {
@@ -335,8 +336,9 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
} }
// For Gitea // For Gitea
func (c *caller) setReusedWorkflowJobResult(jobName, result string) { // Keyed by job id, not name: only the values are read, and masking can collapse two names into one.
func (c *caller) setReusedWorkflowJobResult(jobID, result string) {
c.updateResultLock.Lock() c.updateResultLock.Lock()
defer c.updateResultLock.Unlock() defer c.updateResultLock.Unlock()
c.reusedWorkflowJobResults[jobName] = result c.reusedWorkflowJobResults[jobID] = result
} }
+2 -1
View File
@@ -24,6 +24,7 @@ import (
"gitea.com/gitea/runner/internal/pkg/labels" "gitea.com/gitea/runner/internal/pkg/labels"
"gitea.com/gitea/runner/internal/pkg/lock" "gitea.com/gitea/runner/internal/pkg/lock"
"gitea.com/gitea/runner/internal/pkg/metrics" "gitea.com/gitea/runner/internal/pkg/metrics"
"gitea.com/gitea/runner/internal/pkg/report"
"gitea.com/gitea/runner/internal/pkg/ver" "gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect" "connectrpc.com/connect"
@@ -283,7 +284,7 @@ func initLogging(cfg *config.Config) {
FullTimestamp: true, FullTimestamp: true,
CallerPrettyfier: callPrettyfier, CallerPrettyfier: callPrettyfier,
} }
log.SetFormatter(format) log.SetFormatter(report.MaskingFormatter(format))
l := cfg.Log.Level l := cfg.Log.Level
if l == "" { if l == "" {
+6
View File
@@ -7,6 +7,7 @@ import (
"testing" "testing"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/report"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -57,10 +58,15 @@ func TestInitLoggingSetsLevelAndCaller(t *testing.T) {
log.SetReportCaller(oldReportCaller) log.SetReportCaller(oldReportCaller)
}) })
oldFormatter := log.StandardLogger().Formatter
t.Cleanup(func() { log.SetFormatter(oldFormatter) })
cfg := &config.Config{} cfg := &config.Config{}
cfg.Log.Level = "debug" cfg.Log.Level = "debug"
initLogging(cfg) initLogging(cfg)
require.Equal(t, log.DebugLevel, log.GetLevel()) require.Equal(t, log.DebugLevel, log.GetLevel())
require.True(t, log.StandardLogger().ReportCaller) require.True(t, log.StandardLogger().ReportCaller)
// act plans a job on this logger, so a live task's secrets have to be masked out of it
require.IsType(t, report.MaskingFormatter(nil), log.StandardLogger().Formatter)
} }
+1
View File
@@ -519,6 +519,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
Env: envs, Env: envs,
ProxyEnv: proxyEnv, ProxyEnv: proxyEnv,
Secrets: task.Secrets, Secrets: task.Secrets,
ExtraMasks: append(proxyPasswords(), preset.Token),
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"), GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
NoSkipCheckout: true, NoSkipCheckout: true,
DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv, DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv,
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package report
import (
"strings"
"sync"
"sync/atomic"
"gitea.com/gitea/runner/act/runner"
log "github.com/sirupsen/logrus"
)
// A job's plan runs before its job logger exists and logs through the process-wide one, which
// several tasks share, so that one holds the union of every live task's starting secrets.
var (
globalMu sync.Mutex
globalMasks = map[*Reporter][]string{}
globalReplacer atomic.Pointer[strings.Replacer] // nil while nothing is registered
)
func registerGlobalMasks(r *Reporter) {
globalMu.Lock()
defer globalMu.Unlock()
globalMasks[r] = r.oldnew
rebuildGlobalReplacer()
}
func deregisterGlobalMasks(r *Reporter) {
globalMu.Lock()
defer globalMu.Unlock()
delete(globalMasks, r)
rebuildGlobalReplacer()
}
func rebuildGlobalReplacer() { // caller holds globalMu
var oldnew []string
for _, masks := range globalMasks {
oldnew = append(oldnew, masks...)
}
if len(oldnew) == 0 {
globalReplacer.Store(nil)
return
}
globalReplacer.Store(runner.NewSecretReplacer(oldnew))
}
// MaskingFormatter wraps f so a registered value cannot reach the process-wide log, fields included.
func MaskingFormatter(f log.Formatter) log.Formatter {
return &maskingFormatter{inner: f}
}
type maskingFormatter struct{ inner log.Formatter }
func (m *maskingFormatter) Format(entry *log.Entry) ([]byte, error) {
line, err := m.inner.Format(entry)
if err != nil {
return nil, err
}
replacer := globalReplacer.Load()
if replacer == nil { // nothing to hide, so an idle daemon pays no copy
return line, nil
}
return []byte(replacer.Replace(string(line))), nil
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package report
import (
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGlobalMasks(t *testing.T) {
formatter := MaskingFormatter(&log.TextFormatter{DisableTimestamp: true})
format := func(job string) string {
line, err := formatter.Format(log.WithField("job", job))
require.NoError(t, err)
return string(line)
}
register := func(oldnew ...string) *Reporter {
r := &Reporter{oldnew: oldnew}
registerGlobalMasks(r)
t.Cleanup(func() { deregisterGlobalMasks(r) })
return r
}
assert.Contains(t, format("build s3cr3t"), "s3cr3t")
first := register("s3cr3t", "***")
second := register("other", "***", "otherlonger", "***")
line := format("build s3cr3t and other")
assert.NotContains(t, line, "s3cr3t") // masked though it rode a field, not the message
assert.NotContains(t, line, "other")
assert.NotContains(t, format("otherlonger"), "longer") // longest first, so not "***longer"
deregisterGlobalMasks(first)
line = format("build s3cr3t and other")
assert.Contains(t, line, "s3cr3t")
assert.NotContains(t, line, "other") // the task still running keeps its own
deregisterGlobalMasks(second)
assert.Nil(t, globalReplacer.Load())
// A workflow could otherwise mask "error" here and rewrite every other task's log.
third := register("s3cr3t", "***")
third.addMask("runtime-secret")
assert.Contains(t, format("saw runtime-secret"), "runtime-secret")
assert.NotContains(t, third.mask("saw runtime-secret"), "runtime-secret")
}
+23 -17
View File
@@ -111,16 +111,17 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
if v := task.Context.Fields["gitea_runtime_token"].GetStringValue(); v != "" { if v := task.Context.Fields["gitea_runtime_token"].GetStringValue(); v != "" {
oldnew = runner.AppendSecretMasker(oldnew, v) oldnew = runner.AppendSecretMasker(oldnew, v)
} }
for _, v := range task.Secrets { if v := task.Context.Fields["actions_id_token_request_token"].GetStringValue(); v != "" {
oldnew = runner.AppendSecretMasker(oldnew, v) oldnew = runner.AppendSecretMasker(oldnew, v)
} }
oldnew = runner.AppendSecretMaskers(oldnew, task.Secrets)
rv := &Reporter{ rv := &Reporter{
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
client: client, client: client,
oldnew: oldnew, oldnew: oldnew,
logReplacer: strings.NewReplacer(oldnew...), logReplacer: runner.NewSecretReplacer(oldnew),
logReportInterval: cfg.Runner.LogReportInterval, logReportInterval: cfg.Runner.LogReportInterval,
logReportMaxLatency: cfg.Runner.LogReportMaxLatency, logReportMaxLatency: cfg.Runner.LogReportMaxLatency,
logBatchSize: cfg.Runner.LogReportBatchSize, logBatchSize: cfg.Runner.LogReportBatchSize,
@@ -139,6 +140,8 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
rv.daemonWait = 6 * rv.effectiveCloseTimeout() rv.daemonWait = 6 * rv.effectiveCloseTimeout()
registerGlobalMasks(rv)
if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" { if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
rv.debugOutputEnabled = true rv.debugOutputEnabled = true
} }
@@ -167,12 +170,13 @@ func (r *Reporter) Levels() []log.Level {
return log.AllLevels return log.AllLevels
} }
// appendLogRow buffers a row for the uploader and mirrors it into job.log. A nil row is one // appendLogRow masks a row before buffering it for Gitea and the local job.log, the one point
// the command handler dropped, such as ::add-mask::. Caller holds stateMu. // feeding both. A nil row is one the command handler dropped. Caller holds stateMu.
func (r *Reporter) appendLogRow(row *runnerv1.LogRow) { func (r *Reporter) appendLogRow(row *runnerv1.LogRow) {
if row == nil { if row == nil {
return return
} }
row.Content = r.mask(row.Content)
r.logRows = append(r.logRows, row) r.logRows = append(r.logRows, row)
r.jobLog.write(row.Time.AsTime(), row.Content) r.jobLog.write(row.Time.AsTime(), row.Content)
} }
@@ -223,7 +227,7 @@ func (r *Reporter) Fire(entry *log.Entry) error {
r.stateChanged = true r.stateChanged = true
if log.IsLevelEnabled(log.TraceLevel) { if log.IsLevelEnabled(log.TraceLevel) {
log.WithFields(entry.Data).Trace(entry.Message) log.WithFields(entry.Data).Trace(r.mask(entry.Message)) // the process masker has no ::add-mask:: value
} }
timestamp := entry.Time timestamp := entry.Time
@@ -445,7 +449,7 @@ func (r *Reporter) logf(format string, a ...any) {
if !r.duringSteps() { if !r.duringSteps() {
// Masked like any other row: these bypass parseLogRow, but a caller can still // Masked like any other row: these bypass parseLogRow, but a caller can still
// interpolate a secret, such as a configured URL carrying credentials. // interpolate a secret, such as a configured URL carrying credentials.
r.appendLogRow(r.newLogRow(timestamppb.Now(), fmt.Sprintf(format, a...))) r.appendLogRow(&runnerv1.LogRow{Time: timestamppb.Now(), Content: fmt.Sprintf(format, a...)})
} }
} }
@@ -469,6 +473,11 @@ func (r *Reporter) SetOutputs(outputs map[string]string) {
r.logf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen) r.logf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen)
continue continue
} }
if r.logReplacer.Replace(v) != v { // GitHub skips an output that may carry a secret rather than masking it
log.Warnf("ignore output %q because it may contain a secret", k)
r.logf("ignore output %q because it may contain a secret", k)
continue
}
if _, ok := r.outputs[k]; !ok { if _, ok := r.outputs[k]; !ok {
r.outputs[k] = jobOutput{value: v} r.outputs[k] = jobOutput{value: v}
} }
@@ -476,6 +485,7 @@ func (r *Reporter) SetOutputs(outputs map[string]string) {
} }
func (r *Reporter) Close(lastWords string) error { func (r *Reporter) Close(lastWords string) error {
defer deregisterGlobalMasks(r) // deferred so a panic below cannot strand this task's masks
r.stateMu.Lock() r.stateMu.Lock()
r.closed = true r.closed = true
if r.state.Result == runnerv1.Result_RESULT_UNSPECIFIED { if r.state.Result == runnerv1.Result_RESULT_UNSPECIFIED {
@@ -880,22 +890,18 @@ func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
} }
} }
return r.newLogRow(timestamppb.New(entry.Time), content) return &runnerv1.LogRow{Time: timestamppb.New(entry.Time), Content: content}
}
// newLogRow applies the masking and validation every row must carry, whatever built it.
func (r *Reporter) newLogRow(t *timestamppb.Timestamp, content string) *runnerv1.LogRow {
return &runnerv1.LogRow{
Time: t,
Content: r.mask(content),
}
} }
// mask repairs the content first, so a secret the repair itself spells out is still caught.
func (r *Reporter) mask(content string) string { func (r *Reporter) mask(content string) string {
return strings.ToValidUTF8(r.logReplacer.Replace(content), "?") return r.logReplacer.Replace(strings.ToValidUTF8(content, "?"))
} }
// addMask deliberately leaves the process-wide masker alone. Its entries come from every live
// task at once, so a workflow could otherwise mask "error" there and rewrite the runner's own
// log, and every other task's, for the rest of the job.
func (r *Reporter) addMask(msg string) { func (r *Reporter) addMask(msg string) {
r.oldnew = runner.AppendSecretMasker(r.oldnew, msg) r.oldnew = runner.AppendSecretMasker(r.oldnew, msg)
r.logReplacer = strings.NewReplacer(r.oldnew...) r.logReplacer = runner.NewSecretReplacer(r.oldnew)
} }
+86 -14
View File
@@ -209,7 +209,7 @@ func TestReporter_parseLogRow(t *testing.T) {
got := "<nil>" got := "<nil>"
if rv != nil { if rv != nil {
got = rv.Content got = r.mask(rv.Content)
} }
assert.Equal(t, tt.want[idx], got) assert.Equal(t, tt.want[idx], got)
@@ -226,8 +226,7 @@ func TestReporter_parseLogRowAddMask(t *testing.T) {
assert.Nil(t, r.parseLogRow(&log.Entry{Message: line}), line) assert.Nil(t, r.parseLogRow(&log.Entry{Message: line}), line)
row := r.parseLogRow(&log.Entry{Message: "using supersecret now"}) assert.Equal(t, "using *** now", r.mask("using supersecret now"), line)
assert.Equal(t, "using *** now", row.Content, line)
} }
} }
@@ -1042,12 +1041,16 @@ func TestReporter_StopHeartbeats(t *testing.T) {
} }
func TestAppendLogRow(t *testing.T) { func TestAppendLogRow(t *testing.T) {
r := &Reporter{} r := &Reporter{logReplacer: strings.NewReplacer("supersecret", "***")}
row := &runnerv1.LogRow{Time: timestamppb.Now(), Content: "hello"}
r.appendLogRow(nil) r.appendLogRow(nil)
r.appendLogRow(row) r.appendLogRow(&runnerv1.LogRow{Time: timestamppb.Now(), Content: "hello supersecret"})
r.appendLogRow(nil) require.Len(t, r.logRows, 1)
assert.Equal(t, []*runnerv1.LogRow{row}, r.logRows) assert.Equal(t, "hello ***", r.logRows[0].Content)
// repairing the invalid byte spells out the secret, so the repair has to come first
r = &Reporter{logReplacer: strings.NewReplacer("a?b", "***")}
r.appendLogRow(&runnerv1.LogRow{Time: timestamppb.Now(), Content: "a\xffb"})
assert.Equal(t, "***", r.logRows[0].Content)
} }
func TestReporter_Levels(t *testing.T) { func TestReporter_Levels(t *testing.T) {
@@ -1060,7 +1063,7 @@ func TestReporter_Result(t *testing.T) {
} }
func TestReporter_SetOutputs(t *testing.T) { func TestReporter_SetOutputs(t *testing.T) {
r := &Reporter{state: &runnerv1.TaskState{}, logReplacer: strings.NewReplacer()} r := &Reporter{state: &runnerv1.TaskState{}, logReplacer: strings.NewReplacer("s3cr3t", "***")}
r.SetOutputs(map[string]string{"foo": "bar"}) r.SetOutputs(map[string]string{"foo": "bar"})
got, ok := r.outputs["foo"] got, ok := r.outputs["foo"]
@@ -1083,6 +1086,17 @@ func TestReporter_SetOutputs(t *testing.T) {
_, ok = r.outputs["big"] _, ok = r.outputs["big"]
assert.False(t, ok) assert.False(t, ok)
// a value carrying a secret is skipped, as GitHub does, rather than sent masked
r.SetOutputs(map[string]string{"leaky": "has s3cr3t in it"})
_, ok = r.outputs["leaky"]
assert.False(t, ok)
// invalid UTF-8 is not a secret, so the value is kept as it is rather than dropped
r.SetOutputs(map[string]string{"binary": "caf\xff"})
got, ok = r.outputs["binary"]
require.True(t, ok)
assert.Equal(t, "caf\xff", got.value)
// a value at exactly the limit is still stored // a value at exactly the limit is still stored
maxValue := strings.Repeat("v", maxOutputValueLen) maxValue := strings.Repeat("v", maxOutputValueLen)
r.SetOutputs(map[string]string{"atlimit": maxValue}) r.SetOutputs(map[string]string{"atlimit": maxValue})
@@ -1091,6 +1105,26 @@ func TestReporter_SetOutputs(t *testing.T) {
assert.Len(t, got.value, maxOutputValueLen) assert.Len(t, got.value, maxOutputValueLen)
} }
// Gitea delivers ACTIONS_STEP_DEBUG as a secret, so masking "true" would drop any job output
// saying it. GitHub skips the same two keys.
func TestReporter_DebugSettingsAreNotMasked(t *testing.T) {
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
reporter := NewReporter(context.Background(), nil, nil, &runnerv1.Task{
Context: taskCtx,
Secrets: map[string]string{"ACTIONS_STEP_DEBUG": "true", "ACTIONS_RUNNER_DEBUG": "true", "TOKEN": "s3cr3t"},
}, &config.Config{})
defer deregisterGlobalMasks(reporter)
assert.True(t, reporter.debugOutputEnabled)
assert.Equal(t, "debug is true", reporter.mask("debug is true"))
assert.Equal(t, "***", reporter.mask("s3cr3t"))
reporter.SetOutputs(map[string]string{"changed": "true"})
assert.Equal(t, "true", reporter.outputs["changed"].value) // needs.<job>.outputs.changed == 'true' still works
}
// An output the server acknowledged is not reported again. // An output the server acknowledged is not reported again.
func TestReporter_OutputsSentOnce(t *testing.T) { func TestReporter_OutputsSentOnce(t *testing.T) {
client := mocks.NewClient(t) client := mocks.NewClient(t)
@@ -1160,11 +1194,10 @@ func TestReporter_masksEncodedSecrets(t *testing.T) {
"basic " + base64.StdEncoding.EncodeToString([]byte(secret)), "basic " + base64.StdEncoding.EncodeToString([]byte(secret)),
"https://example.com/?token=" + url.QueryEscape(secret), "https://example.com/?token=" + url.QueryEscape(secret),
} { } {
row := r.parseLogRow(&log.Entry{Message: line}) masked := r.mask(line)
require.NotNil(t, row) assert.Contains(t, masked, "***")
assert.Contains(t, row.Content, "***") assert.NotContains(t, masked, secret)
assert.NotContains(t, row.Content, secret) assert.NotContains(t, masked, base64.StdEncoding.EncodeToString([]byte(secret)))
assert.NotContains(t, row.Content, base64.StdEncoding.EncodeToString([]byte(secret)))
} }
} }
@@ -1297,6 +1330,45 @@ func TestReporter_NoteReport(t *testing.T) {
assert.Contains(t, hook.LastEntry().Message, "reconnected") assert.Contains(t, hook.LastEntry().Message, "reconnected")
} }
// A job's final error can carry a secret, e.g. one interpolated into a failing
// expression, so Close must mask it like every other row.
func TestReporter_CloseMasksLastWords(t *testing.T) {
const secret = "supersecret"
var rows []*runnerv1.LogRow
client := mocks.NewClient(t)
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
rows = append(rows, req.Msg.Rows...)
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
}), nil
},
)
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
const idToken = "id-token-request-secret"
taskCtx, err := structpb.NewStruct(map[string]any{"actions_id_token_request_token": idToken})
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
Context: taskCtx,
Secrets: map[string]string{"TOKEN": secret},
}, cfg)
close(reporter.daemon)
require.NoError(t, reporter.Close("could not get job matrix: "+secret+" "+idToken))
require.Len(t, rows, 1)
assert.Equal(t, "could not get job matrix: *** ***", rows[0].Content)
}
// giteaLogModel mirrors how Gitea stores a task log: UpdateLog appends rows to one // giteaLogModel mirrors how Gitea stores a task log: UpdateLog appends rows to one
// stream, and UpdateTask overwrites the per-step ranges the web UI slices that stream by // stream, and UpdateTask overwrites the per-step ranges the web UI slices that stream by
// (modules/actions/task_state.go, FullSteps). // (modules/actions/task_state.go, FullSteps).