vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Mask secret values in the log as it is written
e82bbe7ddc584dc54ee57c7ce3b97f933c9e9886 · Keenan Nemetz · 7d ago · parent 4ac42680
Mask secret values in the log as it is written A step that prints a secret by accident used to put it in the attempt log, and the log is streamed to the instance and kept. The runner now replaces every value it was given with *** before the bytes reach the log file, so the copy the server stores never holds one and a value split across two writes is still caught: the mask holds back the last longest-1 bytes and the runner flushes at the end of each step. ci/redact is the one implementation, so a local `vole workflow run` masks its terminal output the same way. Its own error text goes through the same mask, because "the runtime message does not contain a value" should be true rather than assumed. It is best effort, and the docs say where it stops: a re-encoded value does not match, a value shorter than four bytes is left alone because masking every occurrence of it would rewrite the log, and an artifact or a site file is published exactly as the step wrote it. Slice 3 of #154.
ci/agent/agent.go +25−1
| @@ -31,6 +31,7 @@ import ( | |||
| 31 | 31 | "github.com/rodent-software/vole/ci" | |
| 32 | 32 | jobcache "github.com/rodent-software/vole/ci/cache" | |
| 33 | 33 | "github.com/rodent-software/vole/ci/container" | |
| 34 | "github.com/rodent-software/vole/ci/redact" | ||
| 34 | 35 | "github.com/rodent-software/vole/ci/runs" | |
| 35 | 36 | "github.com/rodent-software/vole/client" | |
| 36 | 37 | "github.com/rodent-software/vole/forge/access" | |
| @@ -510,6 +511,13 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur | |||
| 510 | 511 | return | |
| 511 | 512 | } | |
| 512 | 513 | defer log.Close() | |
| 514 | // The values are masked as the log is written, so neither the file the streamer | ||
| 515 | // sends nor the instance's copy ever holds one. Errors the runner itself prints | ||
| 516 | // go through the same mask: an instance error or a runtime message is not | ||
| 517 | // supposed to contain a value, and this is what makes that true rather than | ||
| 518 | // assumed. | ||
| 519 | mask := redact.New(secretValues(job)) | ||
| 520 | log.SetMask(mask) | ||
| 513 | 521 | s.stream = s.startLogStream(job, log) | |
| 514 | 522 | defer func() { | |
| 515 | 523 | s.stream.stop() | |
| @@ -532,7 +540,7 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur | |||
| 532 | 540 | return | |
| 533 | 541 | } | |
| 534 | 542 | fail := func(format string, args ...any) { | |
| 535 | msg := fmt.Sprintf(format, args...) | ||
| 543 | msg := mask.String(fmt.Sprintf(format, args...)) | ||
| 536 | 544 | fmt.Fprintf(os.Stderr, "vole runner: %s\n", msg) | |
| 537 | 545 | fmt.Fprintf(log, "\n%s\n", msg) | |
| 538 | 546 | s.finish(remote, co, job, log, 1, nil, nil, nil, box) | |
| @@ -645,6 +653,9 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur | |||
| 645 | 653 | started := time.Now().UTC() | |
| 646 | 654 | fmt.Fprintf(log, "\n--- %s\n", step.Name) | |
| 647 | 655 | code, lost := s.execStep(job.ID, sandbox, step, lease, log) | |
| 656 | // The mask holds back a few bytes so a value split across writes is caught; | ||
| 657 | // flushing here keeps this step's byte range its own. | ||
| 658 | log.Flush() | ||
| 648 | 659 | result := runs.StepResult{ | |
| 649 | 660 | Name: step.Name, Started: started, Finished: time.Now().UTC(), Exit: code, | |
| 650 | 661 | LogOffset: logStart, LogSize: log.Len() - logStart, | |
| @@ -1124,6 +1135,19 @@ func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID str | |||
| 1124 | 1135 | } | |
| 1125 | 1136 | } | |
| 1126 | 1137 | ||
| 1138 | // secretValues is what the log has to redact: the values this job was actually | ||
| 1139 | // given, not the names it asked for. | ||
| 1140 | func secretValues(job *runnerJob) []string { | ||
| 1141 | if len(job.Secrets) == 0 { | ||
| 1142 | return nil | ||
| 1143 | } | ||
| 1144 | values := make([]string, 0, len(job.Secrets)) | ||
| 1145 | for _, delivered := range job.Secrets { | ||
| 1146 | values = append(values, delivered.Value) | ||
| 1147 | } | ||
| 1148 | return values | ||
| 1149 | } | ||
| 1150 | |||
| 1127 | 1151 | // secretsScript renders a job's delivered values as the file every step sources. | |
| 1128 | 1152 | // The rendering lives in ci/container because that is the package that sources it, | |
| 1129 | 1153 | // so a local run writes exactly the same file a runner does. | |