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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · ci/redact/redact.go

Line attribution on main · 180 line(s)

e82bbe7d
Keenan Nemetz
7d ago
1 // Package redact keeps a job's secret values out of the text it produces.
2 //
3 // The attempt log is written on the runner and streamed to the instance, so the
4 // runner is the last place a value can be removed before it becomes part of a
5 // record somebody reads. A local run has the same problem with a terminal and a
6 // scrollback buffer. This package is the one implementation both use: it replaces
7 // each delivered value with Placeholder as the bytes go by, and it holds back the
8 // last few bytes of a write so a value split across two writes is still caught.
9 //
10 // It is not a guarantee. A value that has been re-encoded — base64, hex, a URL
11 // escape, one piece of a connection string — no longer matches the bytes that were
12 // delivered, and no amount of scanning finds it. A value shorter than MinBytes is
13 // not masked at all: replacing every occurrence of "1" or "yes" would rewrite the
14 // output and protect nothing that was worth calling a secret.
15 package redact
16
17 import (
18 "bytes"
19 "io"
20 "sort"
21 "sync"
22 )
23
24 const (
25 // Placeholder is what a masked value becomes. It is short on purpose: a masked
26 // log should still read as a log.
27 Placeholder = "***"
28 // MinBytes is the shortest value worth masking. A three-byte string has 32
29 // possibilities per byte and is a flag, not a credential.
30 MinBytes = 4
31 )
32
33 // Mask is a set of values to remove from a stream of bytes.
34 //
35 // A Mask is not safe for concurrent use. A stream that is written from more than
36 // one goroutine uses a Writer, which serialises writes and owns its Mask.
37 type Mask struct {
38 patterns [][]byte
39 longest int
40 pending []byte
41 }
42
43 // New builds a mask from the values a job was given. Short values are dropped, and
44 // the longest match wins where one value is a prefix of another, so the tail of a
45 // longer value is not left behind.
46 func New(values []string) *Mask {
47 m := &Mask{}
48 seen := make(map[string]bool, len(values))
49 for _, value := range values {
50 if len(value) < MinBytes || seen[value] {
51 continue
52 }
53 seen[value] = true
54 m.patterns = append(m.patterns, []byte(value))
55 }
56 sort.Slice(m.patterns, func(i, j int) bool { return len(m.patterns[i]) > len(m.patterns[j]) })
57 for _, pattern := range m.patterns {
58 m.longest = max(m.longest, len(pattern))
59 }
60 return m
61 }
62
63 // Empty reports whether there is nothing to mask, so a caller can skip the work.
64 func (m *Mask) Empty() bool { return m == nil || len(m.patterns) == 0 }
65
66 // Write takes one write's bytes and returns the prefix that can be emitted now,
67 // already masked. The last longest-1 bytes are held back, because a value may
68 // continue in the next write; Flush emits them.
69 func (m *Mask) Write(p []byte) []byte {
70 if m.Empty() {
71 return p
72 }
73 m.pending = append(m.pending, p...)
74 keep := m.longest - 1
75 if len(m.pending) <= keep {
76 return nil
77 }
78 cut := len(m.pending) - keep
79 out, taken := m.redactTo(m.pending, cut)
80 // A match may have started before the cut and ended after it; it was emitted
81 // whole, so it is consumed whole.
82 m.pending = append(m.pending[:0], m.pending[taken:]...)
83 return out
84 }
85
86 // Flush returns the held-back bytes, masked, and clears them. It is called when a
87 // step ends and when the log is sealed, so the tail is never lost.
88 func (m *Mask) Flush() []byte {
89 if m.Empty() || len(m.pending) == 0 {
90 return nil
91 }
92 out, _ := m.redactTo(m.pending, len(m.pending))
93 m.pending = nil
94 return out
95 }
96
97 // String masks one complete message, such as a runner's own error text, without
98 // touching the carry of the stream. It is for callers that have a whole string and
99 // not a stream.
100 func (m *Mask) String(s string) string {
101 if m.Empty() || s == "" {
102 return s
103 }
104 buf := []byte(s)
105 out, _ := m.redactTo(buf, len(buf))
106 return string(out)
107 }
108
109 // redactTo masks occurrences in buf that start before cut and reports how many
110 // input bytes it consumed, which can run past cut when a match spans it.
111 func (m *Mask) redactTo(buf []byte, cut int) (out []byte, taken int) {
112 out = make([]byte, 0, cut)
113 i := 0
114 for i < cut {
115 if n := m.matchAt(buf, i); n > 0 {
116 out = append(out, Placeholder...)
117 i += n
118 continue
119 }
120 out = append(out, buf[i])
121 i++
122 }
123 return out, i
124 }
125
126 // matchAt is the length of the longest value that starts at i, or zero.
127 func (m *Mask) matchAt(buf []byte, i int) int {
128 for _, pattern := range m.patterns {
129 if len(pattern) <= len(buf)-i && bytes.Equal(buf[i:i+len(pattern)], pattern) {
130 return len(pattern)
131 }
132 }
133 return 0
134 }
135
136 // Writer masks a stream and appends it to another writer. A command's stdout and
137 // stderr are copied by two goroutines, so one Writer may be written from both; the
138 // mutex makes that a serialised stream, which is what the log is anyway.
139 type Writer struct {
140 mu sync.Mutex
141 m *Mask
142 out io.Writer
143 }
144
145 // NewWriter wraps out and masks these values.
146 func NewWriter(out io.Writer, values []string) *Writer {
147 return &Writer{m: New(values), out: out}
148 }
149
150 // Write masks as much of p as can be emitted and reports the whole write as taken,
151 // so a masked value never stops a program with a short write.
152 func (w *Writer) Write(p []byte) (int, error) {
153 w.mu.Lock()
154 defer w.mu.Unlock()
155 if !w.m.Empty() {
156 if err := w.write(w.m.Write(p)); err != nil {
157 return 0, err
158 }
159 return len(p), nil
160 }
161 return w.out.Write(p)
162 }
163
164 // Flush writes whatever the mask held back. A caller calls it when the stream ends.
165 func (w *Writer) Flush() error {
166 w.mu.Lock()
167 defer w.mu.Unlock()
168 if w.m.Empty() {
169 return nil
170 }
171 return w.write(w.m.Flush())
172 }
173
174 func (w *Writer) write(b []byte) error {
175 if len(b) == 0 {
176 return nil
177 }
178 _, err := w.out.Write(b)
179 return err
180 }