Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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/redact/redact_test.go +130

@@ -0,0 +1,130 @@
1 package redact
2
3 import (
4 "bytes"
5 "strings"
6 "sync"
7 "testing"
8 )
9
10 // A value split across two writes is the case the carry exists for: a step's
11 // output arrives in whatever sizes the pipe gives it, and a token has no reason to
12 // land on a boundary.
13 func TestAValueSplitAcrossWritesIsStillMasked(t *testing.T) {
14 t.Parallel()
15 var out bytes.Buffer
16 w := NewWriter(&out, []string{"s3cret-token"})
17 for _, piece := range []string{"the token is s3c", "ret-to", "ken, ok"} {
18 if _, err := w.Write([]byte(piece)); err != nil {
19 t.Fatal(err)
20 }
21 }
22 if err := w.Flush(); err != nil {
23 t.Fatal(err)
24 }
25 if got := out.String(); got != "the token is "+Placeholder+", ok" {
26 t.Fatalf("output = %q", got)
27 }
28 }
29
30 func TestMaskKeepsTheLongestValue(t *testing.T) {
31 t.Parallel()
32 out := (&strings.Builder{})
33 w := NewWriter(out, []string{"abc", "abcdefgh", "abcdef"})
34 if _, err := w.Write([]byte("value abcdefgh")); err != nil {
35 t.Fatal(err)
36 }
37 if err := w.Flush(); err != nil {
38 t.Fatal(err)
39 }
40 // "abc" and "abcdef" are prefixes of "abcdefgh"; the whole value is replaced, so
41 // no tail of it survives.
42 if got := out.String(); got != "value "+Placeholder {
43 t.Fatalf("output = %q, want the longest value masked whole", got)
44 }
45 }
46
47 func TestShortValuesAreNotMasked(t *testing.T) {
48 t.Parallel()
49 out := &strings.Builder{}
50 w := NewWriter(out, []string{"yes", "1", "abc"})
51 if _, err := w.Write([]byte("yes 1 abc and abcd")); err != nil {
52 t.Fatal(err)
53 }
54 if err := w.Flush(); err != nil {
55 t.Fatal(err)
56 }
57 // Three bytes is a flag or a version; masking it would rewrite the log.
58 if got := out.String(); got != "yes 1 abc and abcd" {
59 t.Fatalf("output = %q", got)
60 }
61 // Four is the floor.
62 out.Reset()
63 if _, err := NewWriter(out, []string{"abcd"}).Write([]byte("abcd")); err != nil {
64 t.Fatal(err)
65 }
66 if got := out.String(); got != Placeholder {
67 t.Fatalf("a four-byte value was not masked: %q", got)
68 }
69 }
70
71 func TestMultiLineValuesAreMasked(t *testing.T) {
72 t.Parallel()
73 value := "-----BEGIN KEY-----\nMIIB\n-----END KEY-----"
74 out := &strings.Builder{}
75 w := NewWriter(out, []string{value})
76 if _, err := w.Write([]byte("key:\n" + value + "\ndone\n")); err != nil {
77 t.Fatal(err)
78 }
79 if err := w.Flush(); err != nil {
80 t.Fatal(err)
81 }
82 if got := out.String(); got != "key:\n"+Placeholder+"\ndone\n" {
83 t.Fatalf("output = %q", got)
84 }
85 }
86
87 func TestEveryWriteIsReportedAsTaken(t *testing.T) {
88 t.Parallel()
89 var out bytes.Buffer
90 w := NewWriter(&out, []string{"s3cret"})
91 // A short write would make exec kill the program that printed its own secret.
92 n, err := w.Write([]byte("s3cret-ish"))
93 if err != nil || n != len("s3cret-ish") {
94 t.Fatalf("Write = %d, %v", n, err)
95 }
96 }
97
98 func TestStringMasksAWholeMessage(t *testing.T) {
99 t.Parallel()
100 m := New([]string{"s3cret"})
101 if got := m.String("cannot start: s3cret"); got != "cannot start: "+Placeholder {
102 t.Fatalf("String = %q", got)
103 }
104 if New(nil).String("plain") != "plain" {
105 t.Fatal("an empty mask changed a message")
106 }
107 }
108
109 func TestWriterIsSafeFromTwoGoroutines(t *testing.T) {
110 t.Parallel()
111 out := &strings.Builder{}
112 w := NewWriter(out, []string{"s3cret"})
113 var wg sync.WaitGroup
114 for i := 0; i < 8; i++ {
115 wg.Add(1)
116 go func() {
117 defer wg.Done()
118 for j := 0; j < 50; j++ {
119 _, _ = w.Write([]byte("x s3cret y\n"))
120 }
121 }()
122 }
123 wg.Wait()
124 if err := w.Flush(); err != nil {
125 t.Fatal(err)
126 }
127 if strings.Contains(out.String(), "s3cret") {
128 t.Fatalf("a value survived concurrent writes:\n%s", out.String())
129 }
130 }