1
0
Fork 0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2025-09-15 18:57:01 +00:00

feat: reporter helper to mask secrets, including multiline

- the longest secret is masked first
- multiline secrets are masked before single line secrets
- O(multiline * log rows) to not degrade performances when there are
  no multiline secrets
This commit is contained in:
Earl Warren 2025-07-05 10:36:05 +02:00
parent 9650eb8a46
commit 190079b7f3
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
2 changed files with 403 additions and 0 deletions

146
internal/pkg/report/mask.go Normal file
View file

@ -0,0 +1,146 @@
// Copyright 2025 The Forgejo Authors.
// SPDX-License-Identifier: MIT
package report
import (
"cmp"
"slices"
"strings"
runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1"
)
type masker struct {
replacer *strings.Replacer
lines []string
multiLines [][]string
}
func newMasker() *masker {
return &masker{
lines: make([]string, 0, 10),
multiLines: make([][]string, 0, 10),
}
}
func (o *masker) add(secret string) {
if len(secret) == 0 {
return
}
o.replacer = nil
lines := strings.Split(strings.ReplaceAll(secret, "\r\n", "\n"), "\n")
if len(lines) > 1 {
o.multiLines = append(o.multiLines, lines)
// make sure the longest secret are replaced first
slices.SortFunc(o.multiLines, func(a, b []string) int {
return cmp.Compare(len(b), len(a))
})
} else {
o.lines = append(o.lines, lines[0])
// make sure the longest secret are replaced first
slices.SortFunc(o.lines, func(a, b string) int {
return cmp.Compare(len(b), len(a))
})
}
}
func (o *masker) getReplacer() *strings.Replacer {
if o.replacer == nil {
oldnew := make([]string, 0, len(o.lines)*2)
for _, line := range o.lines {
oldnew = append(oldnew, line, "***")
}
o.replacer = strings.NewReplacer(oldnew...)
}
return o.replacer
}
func (o *masker) replaceLines(rows []*runnerv1.LogRow) {
r := o.getReplacer()
for _, row := range rows {
row.Content = r.Replace(row.Content)
}
}
func (o *masker) maybeReplaceMultiline(multiLine []string, rows []*runnerv1.LogRow) bool {
equal, needMore := o.equalMultiline(multiLine, rows)
if needMore {
return needMore
}
if equal {
o.replaceMultiline(multiLine, rows)
}
return false
}
func (o *masker) trimEOL(s string) string {
return strings.TrimRightFunc(s, func(r rune) bool { return r == '\r' || r == '\n' })
}
func (o *masker) equalMultiline(multiLine []string, rows []*runnerv1.LogRow) (equal, needMore bool) {
if len(rows) < 2 {
needMore = true
return
}
lastIndex := len(multiLine) - 1
first := multiLine[0]
if !strings.HasSuffix(o.trimEOL(rows[0].Content), first) {
return // unreachable because the caller checks that already
}
for i, line := range multiLine[1:lastIndex] {
rowIndex := i + 1
if rowIndex >= len(rows) {
needMore = true
return
}
if o.trimEOL(rows[rowIndex].Content) != line {
return
}
}
last := multiLine[lastIndex]
if lastIndex >= len(rows) {
needMore = true
return
}
if !strings.HasPrefix(o.trimEOL(rows[lastIndex].Content), last) {
return
}
equal = true
return
}
func (o *masker) replaceMultiline(multiLine []string, rows []*runnerv1.LogRow) {
lastIndex := len(multiLine) - 1
first := multiLine[0]
rows[0].Content = strings.TrimSuffix(rows[0].Content, first) + "***"
for _, row := range rows[1:lastIndex] {
row.Content = "***"
}
last := multiLine[lastIndex]
rows[lastIndex].Content = "***" + strings.TrimPrefix(rows[lastIndex].Content, last)
}
func (o *masker) replaceMultilines(rows []*runnerv1.LogRow) bool {
for _, multiLine := range o.multiLines {
for i, row := range rows {
if strings.HasSuffix(o.trimEOL(row.Content), multiLine[0]) {
needMore := o.maybeReplaceMultiline(multiLine, rows[i:])
if needMore {
return needMore
}
}
}
}
return false
}
func (o *masker) replace(rows []*runnerv1.LogRow, noMore bool) bool {
needMore := o.replaceMultilines(rows)
if !noMore && needMore {
return needMore
}
o.replaceLines(rows)
return false
}

View file

@ -0,0 +1,257 @@
// Copyright 2025 The Forgejo Authors.
// SPDX-License-Identifier: MIT
package report
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestReporterMask(t *testing.T) {
lineOne := "secretOne"
lineTwo := lineOne + "secretTwoIsLongerThanAndStartsWithIt"
multiLineMixedSeparators := "A\nB\r\nC\r\nD"
multiLineOne := lineOne + `
TWO
THREE`
multiLineTwo := multiLineOne + `
FOUR
FIVE
SIX`
for _, testCase := range []struct {
name string
secrets []string
in string
out string
noMore bool
needMore bool
}{
{
//
// a multiline secret is masked
//
name: "MultilineIsMasked",
secrets: []string{
multiLineOne,
},
in: fmt.Sprintf("line before\n%[1]s\nline after", multiLineOne),
out: "line before\n***\n***\n***\nline after\n",
needMore: false,
},
{
//
// in a multiline secret \r\n is equivalent to \n and does
// not change how it is masked
//
name: "MultilineWithMixedLineSeparatorsIsMasked",
secrets: []string{
multiLineMixedSeparators,
},
in: fmt.Sprintf("line before\n%[1]s\nline after", multiLineMixedSeparators),
out: "line before\n***\n***\n***\n***\nline after\n",
needMore: false,
},
{
//
// the last line of a multline secret is not a match
//
//
name: "MultilineLastLineDoesNotMatch",
secrets: []string{
multiLineOne,
},
in: fmt.Sprintf("%s\nTWO\nsomethingelse", lineOne),
out: lineOne + "\nTWO\nsomethingelse\n",
needMore: false,
},
{
//
// non-multine secrets are masked
//
name: "SecretsAreMasked",
secrets: []string{
"",
lineOne,
lineTwo,
},
in: fmt.Sprintf("line before\n%[1]s\n%[2]s\nline after", lineOne, lineTwo),
out: "line before\n***\n***\nline after\n",
needMore: false,
},
{
//
// the first line of a multiline secret may be found
// at the end of a line and the last line may be followed
// by a suffix, e.g.
//
// >>>ONE
// TWO
// THREE<<<
//
// and is expected to be replaced with
//
// >>>***
// ***
// ***<<<
//
name: "MultilineWithSuffixAndPrefix",
secrets: []string{
multiLineOne,
},
in: fmt.Sprintf(">>>%[1]s<<<", multiLineOne),
out: ">>>***\n***\n***<<<\n",
needMore: false,
},
{
//
// multiline secrets are considered first
// since only the first line of the multiLineOne secret
// is found, it needs more input to decide and does not
// mask anything.
//
// non-multiline secrets are not considered at all if
// a multiline secret needs more input and the
// lineOne secret is not masked even though it is found
//
// the first lines is found but not the second
//
name: "NeedMoreLines",
secrets: []string{
lineOne,
multiLineOne,
},
in: lineOne,
out: lineOne + "\n",
needMore: true,
},
{
//
// the lines up to but not including the last are found
//
// See NeedMoreLines
//
name: "NeedMoreLinesVariation1",
secrets: []string{
multiLineOne,
},
in: fmt.Sprintf("%s\nTWO", lineOne),
out: lineOne + "\nTWO\n",
needMore: true,
},
{
//
// the lines up to the third out of six are found
//
// See NeedMoreLines
//
name: "NeedMoreLinesVariation2",
secrets: []string{
multiLineTwo,
},
in: multiLineOne,
out: multiLineOne + "\n",
needMore: true,
},
{
//
// a multiline secret will be masked if it is found
// even when another multiline secret needs more input
//
// however non-multiline secrets will not be masked
//
name: "NeedMoreLinesAndMultilinePartialMasking",
secrets: []string{
lineOne,
multiLineOne,
},
in: fmt.Sprintf(`%[1]s %[2]s
>>>%[3]s<<<
%[1]s`, lineOne, lineTwo, multiLineOne),
out: "secretOne secretOnesecretTwoIsLongerThanAndStartsWithIt\n>>>***\n***\n***<<<\nsecretOne\n",
needMore: true,
},
{
//
// - oneline overlaps with lineTwo
// - oneLine overlaps with multiLineOne and multiLineTwo
// - multiLineOne overlaps with multiLineTwo
//
// they are all masked because the longest secret is masked
// first
//
name: "OverlappingSecrets",
secrets: []string{
lineOne,
lineTwo,
multiLineOne,
multiLineTwo,
},
in: fmt.Sprintf(`[[[%[1]s]]] {{{%[2]s}}}
>>>%[3]s<<<
(((%[4]s)))`, lineOne, lineTwo, multiLineOne, multiLineTwo),
out: `[[[***]]] {{{***}}}
>>>***
***
***<<<
(((***
***
***
***
***
***)))
`,
needMore: false,
},
{
//
// A multiline secret needing more lines does not
// prevent single line secrets from being masked
// when there is no more lines / these are the last
// available lines and there is no sense in hoping
// more will be available later.
//
name: "NeedMoreButNoMore",
secrets: []string{
lineTwo,
multiLineOne,
},
in: fmt.Sprintf(`[[[%[1]s]]] {{{%[2]s\nTWO}}}`, lineTwo, lineOne),
out: "[[[***]]] {{{secretOne\\nTWO}}}\n",
noMore: true,
needMore: false,
},
{
//
// A variation where the partial multiline secret also
// happens to contain a single line secret (TWO) which
// needs to be masked.
//
// See NeedMoreButNoMore
//
name: "NeedMoreButNoMoreAndOverlappingSecret",
secrets: []string{
"TWO",
lineTwo,
multiLineOne,
},
in: fmt.Sprintf(`[[[%[1]s]]] {{{%[2]s\nTWO}}}`, lineTwo, lineOne),
out: "[[[***]]] {{{secretOne\\n***}}}\n",
noMore: true,
needMore: false,
},
} {
t.Run(testCase.name, func(t *testing.T) {
m := newMasker()
for _, secret := range testCase.secrets {
m.add(secret)
}
rows := stringToRows(testCase.in)
needMore := m.replace(rows, testCase.noMore)
assert.Equal(t, testCase.needMore, needMore)
assert.Equal(t, testCase.out, rowsToString(rows))
})
}
}