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:
parent
9650eb8a46
commit
190079b7f3
2 changed files with 403 additions and 0 deletions
146
internal/pkg/report/mask.go
Normal file
146
internal/pkg/report/mask.go
Normal 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
|
||||||
|
}
|
257
internal/pkg/report/mask_test.go
Normal file
257
internal/pkg/report/mask_test.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue