This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

spindle/models: fix secret masker cutting off lines early

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 29, 2026, 2:17 PM +0300) commit 80deba14 parent 9ff3611f change-id ytzvoykr
+135 -14
+27 -11
spindle/models/logger.go
··· 1 1 package models 2 2 3 3 import ( 4 + "bytes" 4 5 "encoding/json" 5 6 "fmt" 6 7 "io" ··· 87 88 88 89 func (w *dataWriter) Write(p []byte) (int, error) { 89 90 w.pending = append(w.pending, p...) 90 - window := w.logger.mask.Window() 91 - // anything within window of the tail might be half a secret, keep 92 - // it buffered 93 - if len(w.pending) <= window { 94 - return len(p), nil 91 + if err := w.flushCompleteLines(); err != nil { 92 + return 0, err 93 + } 94 + return len(p), nil 95 + } 96 + 97 + func (w *dataWriter) flushCompleteLines() error { 98 + limit := len(w.pending) - w.logger.mask.Window() 99 + if limit <= 0 { 100 + return nil 101 + } 102 + 103 + for { 104 + lineEnd := bytes.IndexByte(w.pending[:limit], '\n') 105 + if lineEnd < 0 { 106 + return nil 107 + } 108 + lineEnd++ 109 + line := append([]byte(nil), w.pending[:lineEnd]...) 110 + w.pending = w.pending[lineEnd:] 111 + limit -= lineEnd 112 + if err := w.emit(line); err != nil { 113 + return err 114 + } 95 115 } 96 - emit := w.pending[:len(w.pending)-window] 97 - // copy the tail out, emit still aliases the same backing array 98 - w.pending = append([]byte(nil), w.pending[len(w.pending)-window:]...) 99 - return len(p), w.emit(emit) 100 116 } 101 117 102 - // the writer is done, so a buffered tail can no longer grow into a 103 - // full secret and goes out as-is 118 + // the writer is done, so a buffered tail can no longer grow into a full 119 + // secret and goes out as-is 104 120 func (w *dataWriter) flush() error { 105 121 if len(w.pending) == 0 { 106 122 return nil
+80
spindle/models/logger_test.go
··· 1 1 package models 2 2 3 3 import ( 4 + "encoding/json" 4 5 "os" 5 6 "path/filepath" 6 7 "strings" ··· 9 10 10 11 func testWorkflowId(name string) WorkflowId { 11 12 return WorkflowId{PipelineId: PipelineId{Knot: "knot1", Rkey: "rkey1"}, Name: name} 13 + } 14 + 15 + func readDataContents(t *testing.T, path string) []string { 16 + t.Helper() 17 + raw, err := os.ReadFile(path) 18 + if err != nil { 19 + t.Fatal(err) 20 + } 21 + 22 + var got []string 23 + for _, encoded := range strings.Split(strings.TrimSpace(string(raw)), "\n") { 24 + var line LogLine 25 + if err := json.Unmarshal([]byte(encoded), &line); err != nil { 26 + t.Fatalf("decode log line %q: %v", encoded, err) 27 + } 28 + got = append(got, line.Content) 29 + } 30 + return got 12 31 } 13 32 14 33 func TestDataWriterMasksSecretSplitAcrossWrites(t *testing.T) { ··· 44 63 if !strings.Contains(string(raw), "suffix") { 45 64 t.Errorf("log lost trailing output: %s", raw) 46 65 } 66 + if got := strings.Join(readDataContents(t, filepath.Join(dir, wid.String()+".log")), "\n"); got != "prefix *** suffix" { 67 + t.Errorf("masked output changed: %q", got) 68 + } 47 69 } 48 70 49 71 func TestDataWriterMasksSingleFrame(t *testing.T) { ··· 100 122 t.Errorf("log missing output: %s", raw) 101 123 } 102 124 } 125 + 126 + func TestDataWriterDoesNotSplitSafeFragmentsIntoLogLines(t *testing.T) { 127 + dir := t.TempDir() 128 + wid := testWorkflowId("line-boundaries") 129 + logger, err := NewFileWorkflowLogger(dir, wid, []string{"a-secret-with-a-long-window"}) 130 + if err != nil { 131 + t.Fatal(err) 132 + } 133 + w := logger.DataWriter(0, "stdout") 134 + want := []string{ 135 + "first line", 136 + "second line", 137 + "third line", 138 + "fourth line", 139 + "fifth line", 140 + "sixth line", 141 + "seventh line", 142 + "eighth line", 143 + } 144 + for _, line := range want { 145 + if _, err := w.Write([]byte(line + "\n")); err != nil { 146 + t.Fatal(err) 147 + } 148 + } 149 + if err := logger.Close(); err != nil { 150 + t.Fatal(err) 151 + } 152 + 153 + got := readDataContents(t, filepath.Join(dir, wid.String()+".log")) 154 + 155 + if joined := strings.Join(got, "\n"); joined != strings.Join(want, "\n") { 156 + t.Fatalf("log content was split at masking window:\n got: %q\nwant: %q", joined, strings.Join(want, "\n")) 157 + } 158 + } 159 + 160 + func TestDataWriterMasksMultilineSecret(t *testing.T) { 161 + dir := t.TempDir() 162 + secret := "line-one\nline-two" 163 + wid := testWorkflowId("multiline-mask") 164 + logger, err := NewFileWorkflowLogger(dir, wid, []string{secret}) 165 + if err != nil { 166 + t.Fatal(err) 167 + } 168 + w := logger.DataWriter(0, "stdout") 169 + chunk := strings.Repeat("p", 40) + "\nline-one\nline-two\n" + strings.Repeat("t", 30) + "\nsuffix\n" 170 + if _, err := w.Write([]byte(chunk)); err != nil { 171 + t.Fatal(err) 172 + } 173 + if err := logger.Close(); err != nil { 174 + t.Fatal(err) 175 + } 176 + 177 + got := strings.Join(readDataContents(t, filepath.Join(dir, wid.String()+".log")), "\n") 178 + want := strings.Repeat("p", 40) + "\n***\n***\n" + strings.Repeat("t", 30) + "\nsuffix" 179 + if got != want { 180 + t.Fatalf("multiline secret was not masked: %q", got) 181 + } 182 + }
+15 -3
spindle/models/secret_mask.go
··· 18 18 // Also registers base64-encoded variants of each secret. 19 19 func NewSecretMask(values []string) *SecretMask { 20 20 var pairs []string 21 + add := func(value string) { 22 + if value != "" { 23 + pairs = append(pairs, value, "***") 24 + } 25 + } 21 26 22 27 for _, value := range values { 23 28 if value == "" { 24 29 continue 25 30 } 26 31 27 - pairs = append(pairs, value, "***") 32 + add(value) 33 + // mask each non-empty line of a multiline secret 34 + // output may split a secret over multiple log lines... 35 + for _, line := range strings.FieldsFunc(value, func(r rune) bool { 36 + return r == '\r' || r == '\n' 37 + }) { 38 + add(line) 39 + } 28 40 29 41 b64 := base64.StdEncoding.EncodeToString([]byte(value)) 30 42 if b64 != value { 31 - pairs = append(pairs, b64, "***") 43 + add(b64) 32 44 } 33 45 34 46 b64NoPad := strings.TrimRight(b64, "=") 35 47 if b64NoPad != b64 && b64NoPad != value { 36 - pairs = append(pairs, b64NoPad, "***") 48 + add(b64NoPad) 37 49 } 38 50 } 39 51
+13
spindle/models/secret_mask_test.go
··· 133 133 t.Errorf("expected %q, got %q", expected, result) 134 134 } 135 135 } 136 + 137 + func TestSecretMask_MultilineSecretLines(t *testing.T) { 138 + mask := NewSecretMask([]string{"line-one\nline-two"}) 139 + 140 + if result := mask.Mask("line-one\nline-two"); result != "***" { 141 + t.Errorf("full multiline secret: expected %q, got %q", "***", result) 142 + } 143 + for _, line := range []string{"line-one", "line-two"} { 144 + if result := mask.Mask(line); result != "***" { 145 + t.Errorf("secret line %q: expected %q, got %q", line, "***", result) 146 + } 147 + } 148 + }