mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-26 13:57:46 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
||||
|
||||
if rc.caller != nil {
|
||||
// set reusable workflow job result
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
|
||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, jobResult) // For Gitea
|
||||
return
|
||||
}
|
||||
|
||||
@@ -487,7 +487,8 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||
if !ok || len(body) == 0 {
|
||||
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))))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1096,3 +1096,37 @@ func TestJobSetContinueOnError(t *testing.T) {
|
||||
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
@@ -122,7 +122,7 @@ func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, m
|
||||
|
||||
logger.SetFormatter(&maskedFormatter{
|
||||
Formatter: logger.Formatter,
|
||||
masker: valueMasker(config.InsecureSecrets, config.Secrets),
|
||||
masker: valueMasker(config.InsecureSecrets, config.maskers()),
|
||||
})
|
||||
rtn := logger.WithFields(logrus.Fields{
|
||||
"job": jobName,
|
||||
@@ -234,6 +234,17 @@ func jsonStringEscapeNoHTML(v string) string {
|
||||
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 {
|
||||
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
|
||||
// raw_output (command/stream) lines; there is no bypass by field.
|
||||
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
||||
var oldnew []string
|
||||
for _, v := range secrets {
|
||||
oldnew = AppendSecretMasker(oldnew, v)
|
||||
}
|
||||
func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
masked = len(*masks)
|
||||
replacer = strings.NewReplacer(pairs...)
|
||||
replacer = NewSecretReplacer(pairs)
|
||||
}
|
||||
cmasker := replacer
|
||||
mu.Unlock()
|
||||
@@ -419,3 +426,39 @@ func checkIfTerminal(w io.Writer) bool {
|
||||
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...)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestValueMasker(t *testing.T) {
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
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") {
|
||||
lentry := masker(&logrus.Entry{
|
||||
Context: ctx,
|
||||
@@ -65,7 +65,7 @@ func TestValueMasker(t *testing.T) {
|
||||
// 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})
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -94,7 +94,7 @@ func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||
// 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})
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
||||
|
||||
for _, tc := range []struct {
|
||||
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.
|
||||
func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||
masks := []string{"s3cr3t value"}
|
||||
masker := valueMasker(false, nil)
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, nil))
|
||||
|
||||
entry := masker(&logrus.Entry{
|
||||
Context: WithMasks(t.Context(), &masks),
|
||||
@@ -131,7 +141,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||
// 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})
|
||||
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:"} {
|
||||
@@ -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
|
||||
// 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"})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
|
||||
}
|
||||
|
||||
if rc.caller != nil {
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
|
||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult)
|
||||
} else {
|
||||
// Serialize this shared Job.Result write against the other matrix combos
|
||||
// and setJobResult (same lockJob key).
|
||||
|
||||
@@ -180,7 +180,8 @@ func (rc *RunContext) GetEnv() map[string]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 {
|
||||
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.
|
||||
rc.result("failure")
|
||||
if rc.caller != nil { // For Gitea
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure")
|
||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "failure")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1116,7 +1117,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
||||
|
||||
if !runJob {
|
||||
if rc.caller != nil { // For Gitea
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, "skipped")
|
||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "skipped")
|
||||
return false, nil
|
||||
}
|
||||
l.WithField("jobResult", "skipped").Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value)
|
||||
|
||||
@@ -36,6 +36,7 @@ type Config struct {
|
||||
JSONLogger bool // use json or text logger
|
||||
Env map[string]string // env for containers
|
||||
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
|
||||
Token string // GitHub token
|
||||
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())
|
||||
}
|
||||
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 {
|
||||
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,
|
||||
}
|
||||
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
|
||||
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
||||
if job := run.Job(); job != nil {
|
||||
@@ -335,8 +336,9 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
|
||||
}
|
||||
|
||||
// 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()
|
||||
defer c.updateResultLock.Unlock()
|
||||
c.reusedWorkflowJobResults[jobName] = result
|
||||
c.reusedWorkflowJobResults[jobID] = result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user