Compare commits

..
Author SHA1 Message Date
anandh8x daabe11164 fix(providers): send stable session headers to OpenCode 2026-09-15 18:02:59 +05:30
3 changed files with 2 additions and 1175 deletions

No files matched your search

-547
View File
@@ -1,547 +0,0 @@
package redaction
import (
"fmt"
"regexp"
"regexp/syntax"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
// A CREDENTIAL IS STILL A CREDENTIAL WITH AN INVISIBLE BYTE IN THE MIDDLE.
//
// The shape matchers (openaiKeyPattern, textSecretPatterns) all describe a
// CONTIGUOUS run of body characters, and none of their character classes admits
// a control or format character. So a single NUL, ESC or zero-width space
// dropped into the body of a key ends the match, and RedactString emits the two
// fragments untouched. Whoever reads that output next, a terminal that eats the
// escape, a log viewer, a JSON consumer that strips control bytes, or a person
// copying the text, sees the original key back, because nothing about those
// characters was ever displayed. Reported in #969 for NUL and ESC; every other
// Cc and Cf character behaves the same way, which is why this is written against
// the classes rather than against the two bytes named there.
//
// HOW: each shape is rewritten so that a run of separators may sit between any
// two characters it consumes, and matched against the ORIGINAL text. Nothing is
// removed before matching, so there are no offsets to map back, and the leading
// \b of every shape is judged by the regexp engine against the real neighbour:
// a key written after a NUL starts at a boundary, exactly as the unsplit key
// does, and a key glued to the end of a word does not.
//
// An earlier version removed the separators first and matched the compacted
// copy. That lost the difference between a separator inside a credential and one
// next to it, and the fixes for that (check the boundary against the source
// afterwards, map offsets back) left a hole: in "xAKIA<NUL>AKIAIOSF<SOH>ODNN7..."
// the leftmost compacted match began at the decoy AKIA, was then rejected for
// the "x" in front of it, and had already consumed the start of the real key, so
// the real key was never considered. Resuming the scan one byte after each
// rejection would close that, but it is quadratic for the greedy shapes on text
// like "ask-ask-ask-..." with a single NUL in it. Keeping the boundary inside
// the pattern lets RE2 do it in one linear pass.
//
// This pass runs AFTER the contiguous matchers, over their output, and never
// across a replacement. A credential that is whole is claimed by its own strict
// match, so two whole credentials with a separator between them stay two
// replacements with the separator still between them. Two split credentials do
// too: see spansFor.
//
// A match never starts or ends on a separator, so the separators on either side
// of a credential stay where they were: "<NUL>AKIA..." becomes
// "<NUL>[REDACTED]", as it does for the unsplit key.
//
// ONE KNOWN LIMIT, from running the contiguous matchers first. When the part of
// a split key BEFORE its first separator is already a complete credential on
// its own, the contiguous matcher claims that part and stops at the separator,
// and what follows is judged by itself. That is the rest of the key, which is no
// credential alone, so it stays visible: a fragment of the key, never the whole
// of it, because the part in front is replaced. For the same reason a credential
// glued straight onto that rest is left, where the unsplit key would have run on
// and swallowed it. Carrying the contiguous match across the separator instead
// would bring back what running first exists to prevent, since nothing tells the
// rest of a key from prose written after a whole one: that prose would be eaten,
// and a following JWT would lose its header.
//
// WHAT THIS DELIBERATELY DOES NOT TREAT AS A SEPARATOR: tab, newline and
// carriage return. Those three are real text structure rather than invisible
// filler, so a credential broken across a line boundary is still two visible
// fragments here and is not covered. Treating them as gaps would also let a
// match span a line break and collapse unrelated lines into one replacement. A
// credential split that way is visible to whoever reads it; the ones handled
// here are not. Invalid UTF-8 is out for the same reason: the engine reads an
// invalid byte as U+FFFD, which is drawn.
// splitSecretSeparator reports a character that can sit inside a credential
// without being seen. Cc covers the C0 and C1 controls (NUL, ESC, DEL, NEL, CSI)
// and Cf the format characters (zero-width space and joiner, word joiner, BOM,
// soft hyphen). Tab, newline and carriage return are excluded: see above.
func splitSecretSeparator(r rune) bool {
switch r {
case '\t', '\n', '\r':
return false
}
return unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r)
}
// containsSplitSeparator is the cheap gate in front of the gap-tolerant pass, so
// text with nothing invisible in it, which is nearly all text, costs one scan and
// no allocation. ASCII is settled from the byte alone; only a byte that could
// begin a multi-byte rune needs decoding, and the C1 and Cf blocks all start
// with a lead byte at or above 0xC2.
func containsSplitSeparator(value string) bool {
for i := 0; i < len(value); i++ {
b := value[i]
if b < utf8.RuneSelf {
if b < 0x20 || b == 0x7f {
if b == '\t' || b == '\n' || b == '\r' {
continue
}
return true
}
continue
}
r, width := utf8.DecodeRuneInString(value[i:])
if splitSecretSeparator(r) {
return true
}
i += width - 1
}
return false
}
// stripSplitSeparators returns value with every splitSecretSeparator removed.
// Used only on a matched credential, to judge it by its visible characters.
func stripSplitSeparators(value string) string {
var builder strings.Builder
builder.Grow(len(value))
for offset := 0; offset < len(value); {
r, width := utf8.DecodeRuneInString(value[offset:])
if !splitSecretSeparator(r) {
builder.WriteString(value[offset : offset+width])
}
offset += width
}
return builder.String()
}
// splitSeparatorClass is splitSecretSeparator as a regexp character class,
// generated from the same Unicode tables and the same three exclusions so the
// class and the predicate cannot disagree.
var splitSeparatorClass = func() string {
var class strings.Builder
class.WriteByte('[')
emit := func(lo, hi rune) {
fmt.Fprintf(&class, `\x{%x}-\x{%x}`, lo, hi)
}
for _, table := range []*unicode.RangeTable{unicode.Cc, unicode.Cf} {
visit := func(lo, hi, stride uint32) {
for r := lo; r <= hi; r += stride {
start := rune(r)
if !splitSecretSeparator(start) {
continue
}
end := start
if stride == 1 {
for next := rune(r) + 1; uint32(next) <= hi && splitSecretSeparator(next); next++ {
end = next
}
r = uint32(end)
}
emit(start, end)
}
}
for _, r := range table.R16 {
visit(uint32(r.Lo), uint32(r.Hi), uint32(r.Stride))
}
for _, r := range table.R32 {
visit(r.Lo, r.Hi, r.Stride)
}
}
class.WriteByte(']')
return class.String()
}()
// splitOpenAIPattern and splitTextPatterns are the shape matchers with a run of
// separators allowed between every two characters they consume, derived from
// the originals rather than written out a second time so the two lists cannot
// drift apart.
var (
splitOpenAIPattern = gapTolerant(openaiKeyPattern)
splitTextPatterns = gapTolerantAll(textSecretPatterns)
)
// splitShape is one credential shape as the split pass uses it.
type splitShape struct {
// free finds the shape anywhere, leftmost first. anchored matches it only
// where the text begins, and whole only when it spans the entire text.
free, anchored, whole *regexp.Regexp
// prefix is the literal every match of the shape begins with.
prefix string
// accept is the test a match has to pass to be a credential, or nil when
// every match is one.
accept func(match string) bool
}
var splitShapes = func() []splitShape {
shapes := []splitShape{newSplitShape(openaiKeyPattern, splitOpenAIPattern, func(match string) bool {
return openAIShapeIsSecret(stripSplitSeparators(match))
})}
for i, pattern := range textSecretPatterns {
shapes = append(shapes, newSplitShape(pattern, splitTextPatterns[i], nil))
}
return shapes
}()
func newSplitShape(original, free *regexp.Regexp, accept func(string) bool) splitShape {
return splitShape{
free: free,
anchored: regexp.MustCompile(`\A(?:` + free.String() + `)`),
whole: regexp.MustCompile(`\A(?:` + free.String() + `)\z`),
prefix: leadingLiteral(original),
accept: accept,
}
}
// leadingLiteral is the literal every match of pattern begins with, read past a
// leading \b, or "" when the pattern does not open with one.
func leadingLiteral(pattern *regexp.Regexp) string {
tree, err := syntax.Parse(pattern.String(), syntax.Perl)
if err != nil {
panic(fmt.Sprintf("redaction: parse %q: %v", pattern.String(), err))
}
nodes := []*syntax.Regexp{tree}
if tree.Op == syntax.OpConcat {
nodes = tree.Sub
}
for _, node := range nodes {
if node.Op == syntax.OpWordBoundary {
continue
}
if node.Op == syntax.OpLiteral {
return string(node.Rune)
}
return ""
}
return ""
}
// maxRunOnCandidates bounds the anchored matches tried inside one match, so a
// match full of lookalike starts still costs a fixed number of them.
const maxRunOnCandidates = 8
// spansFor turns one match of the shape into the stretches to replace.
//
// A MATCH CAN RUN ON INTO THE NEXT CREDENTIAL OF ITS OWN SHAPE. Gaps are allowed
// inside a match, and a separator between two credentials looks exactly like one
// inside a credential, so a greedy body runs through it into whatever follows. A
// credential of another shape is still found, because every shape is matched on
// its own and the union is replaced. One of the SAME shape is not: matches of one
// pattern never overlap, so the second credential cannot start inside the first
// one's match. Most bodies swallow it whole, so nothing leaks, but a JWT stops at
// the next token's first dot, and two split JWTs side by side left the second
// one's payload and signature in the clear. And a match the filter rejects hides
// whatever it ran into, so kebab-case prose in front of a split key took the key
// down with it. Reported by @jatmn.
//
// So when a match runs through a run of separators into a position where the
// same shape matches again and reaches at least as far, that credential is taken
// on its own, and the first is cut at the separators if it is a complete match by
// itself. The separators between the two then stay outside both replacements, as
// they do between two whole credentials, and each piece is filtered on its own.
// When the first is not complete alone, it keeps the whole of its match and the
// union joins the two into one replacement.
//
// ONE STEP PER MATCH, NOT THE WHOLE CHAIN. The second credential's own match can
// run on into a third, and following that from here would walk the rest of a
// chain once for every match inside it, which is quadratic: a megabyte of split
// JWTs ran for minutes. The scan's own next match starts inside the second
// credential and takes the step after it, so the chain is still covered, and in
// a chain of three or more the markers after the first pair can join into one.
func (shape splitShape) spansFor(text string, start, end int) [][]int {
next, gap, nextEnd := shape.runOn(text, start, end)
if next < 0 {
return shape.keep(nil, text, start, end)
}
var spans [][]int
if shape.whole.MatchString(text[start:gap]) {
spans = shape.keep(spans, text, start, gap)
} else {
spans = shape.keep(spans, text, start, end)
}
return shape.keep(spans, text, next, nextEnd)
}
func (shape splitShape) keep(spans [][]int, text string, start, end int) [][]int {
if shape.accept != nil && !shape.accept(text[start:end]) {
return spans
}
return append(spans, []int{start, end})
}
// runOn looks inside the match [start, end) for where it ran through a run of
// separators into another credential of the same shape. It returns where that
// credential begins, where the separator run in front of it begins, and where the
// credential's own match ends, or next = -1 when there is none.
//
// Latest first, because a match reaches into the next credential only as far as
// its own pattern allows. Only where the text after the separators opens with the
// shape's literal prefix, which is where a match of the shape can begin; the
// separator in front supplies the word boundary, so matching from there judges it
// the same as the whole text would.
func (shape splitShape) runOn(text string, start, end int) (next, gap, nextEnd int) {
tried := 0
for at := end; at > start && tried < maxRunOnCandidates; {
r, size := utf8.DecodeLastRuneInString(text[start:at])
if !splitSecretSeparator(r) {
at -= size
continue
}
candidate := at
for at > start {
r, size = utf8.DecodeLastRuneInString(text[start:at])
if !splitSecretSeparator(r) {
break
}
at -= size
}
if !hasGappedPrefix(text[candidate:], shape.prefix) {
continue
}
tried++
if match := shape.anchored.FindStringIndex(text[candidate:]); match != nil && candidate+match[1] >= end {
return candidate, at, candidate + match[1]
}
}
return -1, 0, 0
}
// hasGappedPrefix reports whether text opens with prefix, allowing a run of
// separators between its characters the way the shapes themselves do. A plain
// prefix test missed a token split inside its first few characters, and the run
// that swallowed it then went unnoticed.
func hasGappedPrefix(text, prefix string) bool {
at := 0
for index, want := range prefix {
if index > 0 {
for at < len(text) {
r, size := utf8.DecodeRuneInString(text[at:])
if !splitSecretSeparator(r) {
break
}
at += size
}
}
if at >= len(text) {
return false
}
r, size := utf8.DecodeRuneInString(text[at:])
if r != want {
return false
}
at += size
}
return true
}
func gapTolerantAll(patterns []*regexp.Regexp) []*regexp.Regexp {
tolerant := make([]*regexp.Regexp, 0, len(patterns))
for _, pattern := range patterns {
tolerant = append(tolerant, gapTolerant(pattern))
}
return tolerant
}
// gapTolerant rewrites pattern so that a run of separators may appear before
// every character it consumes except the first. Zero-width assertions, the
// leading \b in particular, are left exactly where they were.
func gapTolerant(pattern *regexp.Regexp) *regexp.Regexp {
tree, err := syntax.Parse(pattern.String(), syntax.Perl)
if err != nil {
panic(fmt.Sprintf("redaction: parse %q: %v", pattern.String(), err))
}
separator, err := syntax.Parse(splitSeparatorClass, syntax.Perl)
if err != nil {
panic(fmt.Sprintf("redaction: parse separator class: %v", err))
}
consumed := false
return regexp.MustCompile(withGaps(tree, separator, &consumed).String())
}
// withGaps inserts the separator run before each consuming atom. consumed
// tracks whether anything has been consumed yet on this path, because the first
// character of a match must be a real one: a leading gap would let a match start
// on a separator and swallow it.
func withGaps(node, separator *syntax.Regexp, consumed *bool) *syntax.Regexp {
switch node.Op {
case syntax.OpLiteral:
parts := make([]*syntax.Regexp, 0, len(node.Rune))
for _, r := range node.Rune {
atom := &syntax.Regexp{Op: syntax.OpLiteral, Rune: []rune{r}, Flags: node.Flags}
parts = append(parts, gapBefore(atom, separator, consumed))
}
if len(parts) == 1 {
return parts[0]
}
return &syntax.Regexp{Op: syntax.OpConcat, Sub: parts}
case syntax.OpCharClass, syntax.OpAnyChar, syntax.OpAnyCharNotNL:
return gapBefore(node, separator, consumed)
case syntax.OpConcat, syntax.OpCapture:
for i := range node.Sub {
node.Sub[i] = withGaps(node.Sub[i], separator, consumed)
}
return node
case syntax.OpAlternate:
start, after := *consumed, *consumed
for i := range node.Sub {
branch := start
node.Sub[i] = withGaps(node.Sub[i], separator, &branch)
after = after || branch
}
*consumed = after
return node
case syntax.OpStar, syntax.OpPlus, syntax.OpQuest, syntax.OpRepeat:
// Every shape opens with a literal prefix, so a repetition is never the
// first thing a match consumes. If one ever is, its first iteration would
// need no gap and the rest would; fail loudly rather than guess.
if !*consumed {
panic("redaction: gap-tolerant rewrite reached a repetition before any literal")
}
node.Sub[0] = withGaps(node.Sub[0], separator, consumed)
return node
case syntax.OpWordBoundary, syntax.OpNoWordBoundary, syntax.OpBeginLine, syntax.OpEndLine,
syntax.OpBeginText, syntax.OpEndText, syntax.OpEmptyMatch:
return node
default:
panic(fmt.Sprintf("redaction: gap-tolerant rewrite does not handle %v", node.Op))
}
}
func gapBefore(atom, separator *syntax.Regexp, consumed *bool) *syntax.Regexp {
if !*consumed {
*consumed = true
return atom
}
gap := &syntax.Regexp{Op: syntax.OpStar, Sub: []*syntax.Regexp{separator}}
return &syntax.Regexp{Op: syntax.OpConcat, Sub: []*syntax.Regexp{gap, atom}}
}
// openAIShapeIsSecret is the digit/kebab-case filter RedactString applies to an
// openaiKeyPattern match, named so both callers state the same rule once.
func openAIShapeIsSecret(match string) bool {
if knownOpenAIKeyPrefix(match) || secretMatchHasDigit(match) {
return true
}
return !strings.Contains(strings.TrimPrefix(match, "sk-"), "-")
}
// redactControlSplitSecrets replaces credentials whose body is interrupted by
// invisible characters. It is a no-op for text containing none of them, which is
// nearly all text, so the contiguous path keeps its behavior exactly.
//
// It runs over the contiguous matchers' output and stops at every replacement
// they left behind: those mark text already accounted for, and a match reaching
// across one would be reaching out of one credential and into the next.
//
// It runs at most twice. The second pass exists for a credential glued straight
// onto the end of another split one: it has no word boundary of its own until
// the first is replaced, and the replacement then supplies one, the same way the
// contiguous shapes cascade into each other. Two passes and no more keeps the
// cost linear; a longer chain of glued split credentials is left as the
// contiguous matchers leave a chain of glued whole ones.
func redactControlSplitSecrets(value, replacement string) string {
if !containsSplitSeparator(value) {
return value
}
once := redactSplitRegions(value, replacement, true)
if once == value {
return value
}
// Ungated: the credential the second pass exists for is often contiguous
// itself (a whole JWT glued onto a split AWS key), so its region can hold no
// separator at all once the first pass has cut the text around it.
return redactSplitRegions(once, replacement, false)
}
func redactSplitRegions(value, replacement string, gated bool) string {
if replacement == "" {
return redactSplitRegion(value, replacement, 0, gated)
}
regions := strings.Split(value, replacement)
if len(regions) == 1 {
return redactSplitRegion(value, replacement, 0, gated)
}
previous := byte(0)
for i, region := range regions {
regions[i] = redactSplitRegion(region, replacement, previous, gated)
previous = replacement[len(replacement)-1]
}
return strings.Join(regions, replacement)
}
// redactSplitRegion runs every gap-tolerant shape over one stretch of text with
// no replacement inside it and replaces the UNION of what they found.
//
// Independently, not one after another. An unbounded body with gaps allowed will
// run on across a separator into whatever follows, and when that is the start of
// another credential it takes that credential's prefix with it: in
// "sk-ant-…<ESC>build<ESC>eyJ….eyJ….sig" the Anthropic shape stops only at the
// JWT's first dot. Applied in sequence, the JWT shape then finds its header gone
// and the payload and signature are left in the clear. Matched against the same
// text, the JWT shape still finds the whole token, and the union covers both.
//
// previous is the byte immediately before the region in the original, or zero at
// the start of the string. It is lent to the engine in front of the region so a
// leading \b is judged against the real neighbour, and a match is never allowed
// to begin on it.
func redactSplitRegion(value, replacement string, previous byte, gated bool) string {
if gated && !containsSplitSeparator(value) {
return value
}
text, lead := value, 0
if previous != 0 {
text, lead = string([]byte{previous})+value, 1
}
var spans [][]int
for _, shape := range splitShapes {
for _, match := range shape.free.FindAllStringIndex(text, -1) {
if match[0] >= lead {
spans = append(spans, shape.spansFor(text, match[0], match[1])...)
}
}
}
return spliceSpans(text, spans, replacement)[lead:]
}
// spliceSpans replaces every named range of value with replacement, merging
// ranges that overlap so one stretch of text is never replaced twice.
func spliceSpans(value string, spans [][]int, replacement string) string {
if len(spans) == 0 {
return value
}
sort.Slice(spans, func(i, j int) bool {
if spans[i][0] != spans[j][0] {
return spans[i][0] < spans[j][0]
}
return spans[i][1] > spans[j][1]
})
var builder strings.Builder
builder.Grow(len(value))
written := 0
for _, span := range spans {
if span[0] < written {
// Overlaps the stretch just replaced: extend it rather than emit a
// second marker, which would claim two credentials where there was one
// stretch of text.
if span[1] > written {
written = span[1]
}
continue
}
builder.WriteString(value[written:span[0]])
builder.WriteString(replacement)
written = span[1]
}
builder.WriteString(value[written:])
return builder.String()
}
-622
View File
@@ -1,622 +0,0 @@
package redaction
import (
"fmt"
"regexp"
"runtime"
"strings"
"testing"
"unicode"
)
const (
awsKey = "AKIAIOSFODNN7EXAMPLE"
anthropicKey = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaa0123456789ABCD"
githubKey = "ghp_cccccccccccccccccccccccccccccccccccc"
openaiKey = "sk-proj-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
)
// rejoin drops every character a reader would never see, which is what a
// terminal, a log viewer or a copy-paste does to this output. If the secret is
// back after that, the redaction did not happen.
func rejoin(value string) string {
return strings.Map(func(r rune) rune {
if splitSecretSeparator(r) {
return -1
}
return r
}, value)
}
// AN INVISIBLE BYTE INSIDE A KEY MUST NOT END THE MATCH.
//
// Every shape matcher describes a contiguous body, so one NUL or ESC in the
// middle of a key ended the match and both fragments were emitted. Dropping the
// byte — which is what the next reader does, because it was never displayed —
// put the original key back. Reported in #969 for NUL and ESC; this covers the
// classes those two belong to, because every member behaves the same way.
func TestRedactStringRedactsSecretsSplitByInvisibleCharacters(t *testing.T) {
separators := map[string]string{
"NUL": "\x00",
"ESC": "\x1b",
"BEL": "\x07",
"backspace": "\x08",
"vertical tab": "\x0b",
"form feed": "\x0c",
"DEL": "\x7f",
"C1 NEL": "\u0085",
"C1 CSI": "\u009b",
"zero width space": "\u200b",
"zero width joiner": "\u200d",
"word joiner": "\u2060",
"byte order mark": "\ufeff",
"soft hyphen": "\u00ad",
}
// Every entry has to BE a separator, or its leg passes vacuously: inserting an
// ordinary character into a key also stops the secret matching, and the rejoin
// below would then find nothing. An escape mangled on the way into this file is
// exactly how that happens.
for name, separator := range separators {
if runes := []rune(separator); len(runes) != 1 || !splitSecretSeparator(runes[0]) {
t.Fatalf("SETUP INVALID: %s is %q, which is not a single invisible separator", name, separator)
}
}
secrets := map[string]string{"aws": awsKey, "anthropic": anthropicKey, "github": githubKey, "openai": openaiKey}
for secretName, secret := range secrets {
// The premise: the unsplit form already redacts, so a failure below is
// about the separator and not about the shape being unknown.
if out := RedactString(secret, Options{}); strings.Contains(out, secret) {
t.Fatalf("SETUP INVALID: the unsplit %s key is not redacted at all: %q", secretName, out)
}
for sepName, separator := range separators {
for _, at := range []int{1, len(secret) / 2, len(secret) - 1} {
split := secret[:at] + separator + secret[at:]
out := RedactString(split, Options{})
if got := rejoin(out); strings.Contains(got, secret) {
t.Errorf("%s key split by %s at byte %d survives: %q rejoins to %q", secretName, sepName, at, out, got)
}
}
}
}
}
// The replacement has to cover the ORIGINAL bytes, separators included. Leaving
// the separator behind would emit `[REDACTED]\x00[REDACTED]` and tell a reader
// there were two secrets, and leaving either fragment behind is the leak itself.
func TestRedactStringCoversTheSeparatorsInsideASplitSecret(t *testing.T) {
for _, testCase := range []struct {
name string
value string
want string
}{
{"one separator", "key=" + awsKey[:8] + "\x00" + awsKey[8:], "key=[REDACTED]"},
{"several separators", "key=" + strings.Join(strings.Split(awsKey, ""), "\x1b"), "key=[REDACTED]"},
{"adjacent separators", "key=" + awsKey[:8] + "\x00\x1b\u200b" + awsKey[8:], "key=[REDACTED]"},
{"two split secrets", awsKey[:8] + "\x00" + awsKey[8:] + " and " + githubKey[:8] + "\x00" + githubKey[8:], "[REDACTED] and [REDACTED]"},
{"separator before the secret", "\x00" + awsKey, "\x00[REDACTED]"},
{"separator after the secret", awsKey + "\x00", "[REDACTED]\x00"},
{"text around it survives", "before " + awsKey[:4] + "\x00" + awsKey[4:] + " after", "before [REDACTED] after"},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, Options{}); got != testCase.want {
t.Errorf("RedactString(...) = %q, want %q", got, testCase.want)
}
})
}
}
// NOTHING ELSE MOVES. The compaction exists to find a match; it must never
// reach text that has no secret in it, and text with no invisible character at
// all must take exactly the path it took before.
func TestRedactStringLeavesOrdinaryTextWithControlCharactersAlone(t *testing.T) {
for _, value := range []string{
"a plain sentence",
"a sentence\x00with a NUL in it",
"\x1b[31mcolored output\x1b[0m",
"tab\tseparated\tcolumns",
"line one\nline two\r\nline three",
"zero\u200bwidth\u200bspaces",
"", "\x00", "\x00\x00\x00",
"not-a-secret-just-a-long-kebab-case-identifier-here",
"sk-some-kebab-case-value-without-any-digits-at-all",
} {
if got := RedactString(value, Options{}); got != value {
t.Errorf("RedactString(%q) = %q, want it unchanged", value, got)
}
}
}
// The openai filter that keeps kebab-case identifiers out of the redactor has
// to apply on the split path too, or working around the false positive would
// become a matter of inserting a control byte.
func TestSplitPathKeepsTheOpenAIKebabCaseFilter(t *testing.T) {
kebab := "sk-some-kebab-case-value-without-digits"
split := kebab[:6] + "\x00" + kebab[6:]
if got := RedactString(split, Options{}); got != split {
t.Errorf("a kebab-case identifier split by a NUL was redacted: %q", got)
}
// ... while a real key of the same family, split the same way, is not spared.
realKey := openaiKey[:6] + "\x00" + openaiKey[6:]
if got := RedactString(realKey, Options{}); strings.Contains(rejoin(got), openaiKey) {
t.Errorf("a real openai key split by a NUL survived: %q", got)
}
}
// TAB, NEWLINE AND CARRIAGE RETURN ARE OUT OF SCOPE ON PURPOSE, and this says
// so in a place that fails if someone changes it without meaning to. They are
// real text structure: stripping them would let one match span a line break and
// replace unrelated lines, and a credential broken across a line is visible to
// whoever reads it rather than hidden from them.
func TestLineStructureIsNotTreatedAsAnInvisibleSeparator(t *testing.T) {
for name, separator := range map[string]string{"tab": "\t", "newline": "\n", "carriage return": "\r"} {
if splitSecretSeparator([]rune(separator)[0]) {
t.Errorf("%s is treated as an invisible separator; if that is now wanted, this test and the note in control_split.go both need to change", name)
}
split := awsKey[:8] + separator + awsKey[8:]
if got := RedactString(split, Options{}); got != split {
t.Errorf("%s split was redacted: %q. That may be an improvement, but it is a scope change this test is here to make deliberate", name, got)
}
}
}
// Malformed UTF-8 must not move the span mapping, which works in the source's
// own byte offsets. A decoder that measured an invalid byte as the three bytes
// of U+FFFD would shift every later span.
// The exact output is asserted, not just the absence of the key. An invalid
// byte measured as three would still cover the secret while moving the
// replacement's edges onto the bytes around it, which reads as a pass if the
// test only asks whether the secret is gone.
func TestSplitRedactionHandlesMalformedUTF8(t *testing.T) {
for _, testCase := range []struct {
name string
value string
want string
}{
{"invalid byte before the secret", "\xff" + awsKey[:8] + "\x00" + awsKey[8:], "\xff[REDACTED]"},
// An invalid byte is NOT one of the separators: it decodes to U+FFFD and
// is drawn, so a key broken by one is visible to whoever reads it, the
// same argument that keeps tab and newline out. It is kept in the
// compacted text and so it still ends the match.
{"invalid byte inside the split", awsKey[:8] + "\x00\xff" + awsKey[8:], awsKey[:8] + "\x00\xff" + awsKey[8:]},
{"invalid byte after the secret", awsKey[:8] + "\x00" + awsKey[8:] + "\xfe", "[REDACTED]\xfe"},
{"invalid bytes on both sides", "\xff\xfe" + awsKey[:8] + "\x00" + awsKey[8:] + "\xfd\xfc", "\xff\xfe[REDACTED]\xfd\xfc"},
{"invalid byte then text then the secret", "\xffpre " + awsKey[:8] + "\x00" + awsKey[8:] + " post", "\xffpre [REDACTED] post"},
{"only invalid bytes", "\xff\xfe\x00\xff", "\xff\xfe\x00\xff"},
} {
t.Run(testCase.name, func(t *testing.T) {
got := RedactString(testCase.value, Options{})
if got != testCase.want {
t.Errorf("RedactString(%q) = %q, want %q", testCase.value, got, testCase.want)
}
if strings.Contains(rejoin(got), awsKey) {
t.Errorf("the key survived: %q", got)
}
})
}
}
// The compaction is only a lens for finding the match. A string with no
// separator at all must come back byte-identical, including its invalid bytes.
func TestCompactionDoesNotRewriteTextItDoesNotRedact(t *testing.T) {
for _, value := range []string{"plain", "with\xffinvalid", "tab\there", "\xef\xbb\xbfbom at the front"} {
if got := redactControlSplitSecrets(value, RedactedSecret); got != value {
t.Errorf("redactControlSplitSecrets(%q) = %q, want it unchanged", value, got)
}
}
}
// The separators the tests below need by name, built from their code points so
// no escape in this file can be mangled into the character it stands for.
var (
nulSeparator = string(rune(0x00))
escSeparator = string(rune(0x1b))
zwspSeparator = string(rune(0x200b))
)
const (
slackKey = "xoxb-EXAMPLE-NOT-A-REAL-TOKEN-AAAAAAAAAA"
googleKey = "AIzaSyD-0123456789abcdefghijklmnopqrstuv"
jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r0W1gFWFOEjXkPY"
otherJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEwIn0.QWxpY2VCb2JDYXJvbERhdmVFdmVGcmFua0dyYWNlSA"
)
// splitInTheMiddle puts one separator inside the body of a secret.
func splitInTheMiddle(secret, separator string) string {
half := len(secret) / 2
return secret[:half] + separator + secret[half:]
}
// A SEPARATOR IN FRONT OF A KEY IS A DELIMITER, ONE INSIDE IT IS FILLER.
//
// #969 asks for one thing: the split form is redacted the same as the unsplit
// form. That is the whole assertion here, run over every kind of byte a key can
// follow. It catches the two ways of getting it wrong from opposite sides. Drop
// the separators and then ask the pattern for a word boundary, and a key written
// after a NUL stops redacting the moment a second separator lands inside it,
// even though the unsplit key after that same NUL redacts today. Ignore the
// boundary instead, and a key glued to the end of a word starts redacting when
// it is split, although the unsplit form never did.
func TestSplitRedactionMatchesTheUnsplitVerdictAtEveryLeadingBoundary(t *testing.T) {
leaders := map[string]string{
"start of string": "",
"space": "before ",
"quote": `{"k":"`,
"equals": "key=",
"NUL": "prefix" + nulSeparator,
"ESC": "prefix" + escSeparator,
"zero width": "prefix" + zwspSeparator,
"letter": "prefix",
"digit": "7",
"underscore": "field_",
}
secrets := map[string]string{"aws": awsKey, "github": githubKey, "openai": openaiKey, "slack": slackKey, "google": googleKey}
for secretName, secret := range secrets {
for leaderName, leader := range leaders {
unsplit := leader + secret
split := leader + splitInTheMiddle(secret, escSeparator)
wantRedacted := !strings.Contains(RedactString(unsplit, Options{}), secret)
out := RedactString(split, Options{})
gotRedacted := !strings.Contains(rejoin(out), secret)
if gotRedacted == wantRedacted {
continue
}
if wantRedacted {
t.Errorf("%s key after a %s leader: the unsplit form redacts and the split form does not: %q rejoins to %q",
secretName, leaderName, out, rejoin(out))
continue
}
t.Errorf("%s key after a %s leader: the unsplit form is left alone and the split form is redacted: %q",
secretName, leaderName, out)
}
}
}
// A MATCH MUST NOT REACH OUT OF ONE CREDENTIAL AND INTO THE NEXT.
//
// The JWT shapes end on a run of body characters with no trailing boundary, so
// a matcher reading a copy with the separators removed can start in the key in
// front and run through the header of the JWT behind it, replacing both with one
// marker and eating the delimiter between them. The contiguous pass claims whole
// credentials before this one runs, which is what keeps them apart.
func TestSplitRedactionKeepsNeighbouringCredentialsApart(t *testing.T) {
separators := map[string]string{"NUL": nulSeparator, "ESC": escSeparator, "zero width": zwspSeparator}
leaders := map[string]string{
"jwt": jwtToken,
"github": githubKey,
"anthropic": anthropicKey,
"openai": openaiKey,
"slack": slackKey,
"google": googleKey,
"aws": awsKey,
}
for leaderName, leader := range leaders {
for sepName, separator := range separators {
value := leader + separator + otherJWT
want := RedactedSecret + separator + RedactedSecret
if got := RedactString(value, Options{}); got != want {
t.Errorf("a %s key %s a JWT: RedactString(...) = %q, want %q", leaderName, sepName, got, want)
}
}
}
}
// splitThroughout puts a separator a quarter of the way into every dot-separated
// segment of a token. Early, so the part in front is too short to be a
// credential by itself and the strict pass cannot claim it; a split whose front
// part is complete alone is the documented limit, not what these tests are about.
func splitThroughout(secret, separator string) string {
parts := strings.Split(secret, ".")
for i, part := range parts {
quarter := len(part) / 4
parts[i] = part[:quarter] + separator + part[quarter:]
}
return strings.Join(parts, ".")
}
// TWO SPLIT CREDENTIALS OF ONE SHAPE ARE TWO CREDENTIALS.
//
// Matches of one pattern never overlap, so when the first one's body runs on
// through the separator between them, the second cannot start a match of its
// own. Whole neighbours are safe because the strict pass claims them first;
// these are both split. A JWT body stops at the next token's first dot and a
// classic GitHub body at the next token's underscore, which left the rest of the
// second credential in the clear, and the bodies that swallow the next key whole
// joined both keys and the separator between them into one replacement.
func TestSplitRedactionKeepsTwoSplitCredentialsOfOneShapeApart(t *testing.T) {
separators := map[string]string{"NUL": nulSeparator, "ESC": escSeparator, "zero width": zwspSeparator}
pairs := map[string][2]string{
"jwt": {jwtToken, otherJWT},
"github": {githubKey, "ghp_ffffffffffffffffffffffffffffffffffff"},
"openai": {openaiKey, "sk-proj-dddddddddddddddddddddddddddddddd"},
"anthropic": {anthropicKey, "sk-ant-api03-eeeeeeeeeeeeeeeeeeeeeeee9876543210WXYZ"},
"slack": {slackKey, "xoxb-ANOTHER-NOT-REAL-TOKEN-BBBBBBBBBBBB"},
}
for name, pair := range pairs {
for sepName, separator := range separators {
value := splitThroughout(pair[0], escSeparator) + separator + splitThroughout(pair[1], escSeparator)
want := RedactedSecret + separator + RedactedSecret
if got := RedactString(value, Options{}); got != want {
t.Errorf("two split %s credentials with %s between them: RedactString(...) = %q, want %q", name, sepName, got, want)
}
}
}
}
// The search for where the next credential begins is bounded, so it has to be
// spent on positions where one can begin. Here the second token's header is split
// at every character inside the part the first match swallowed, which puts dozens
// of separator runs between the end of that match and the real boundary.
func TestSplitRedactionFindsTheNextCredentialPastItsOwnSplits(t *testing.T) {
parts := strings.Split(otherJWT, ".")
header := strings.Join(strings.Split(parts[0], ""), escSeparator)
second := header + "." + strings.Join(parts[1:], ".")
value := splitThroughout(jwtToken, escSeparator) + nulSeparator + second
want := RedactedSecret + nulSeparator + RedactedSecret
if got := RedactString(value, Options{}); got != want {
t.Errorf("RedactString(...) = %q, want %q", got, want)
}
}
// And a chain of them leaks nothing, however the markers fall.
func TestSplitRedactionLeavesNothingOfAChainOfSplitJWTs(t *testing.T) {
chain := splitThroughout(jwtToken, escSeparator) + nulSeparator +
splitThroughout(otherJWT, escSeparator) + nulSeparator +
splitThroughout(jwtToken, zwspSeparator)
out := rejoin(RedactString(chain, Options{}))
for _, segment := range append(strings.Split(jwtToken, "."), strings.Split(otherJWT, ".")...) {
if strings.Contains(out, segment) {
t.Errorf("a JWT segment survived a chain of three split tokens: %q in %q", segment, out)
}
}
}
// A CHAIN OF SPLIT CREDENTIALS COSTS WHAT ITS LENGTH COSTS.
//
// The run-on search once followed a chain to its end from every match inside it,
// which is quadratic in the chain: a megabyte of split JWTs back to back
// allocated seventeen gigabytes and ran for ten minutes. Measured as allocation,
// which that walk multiplies with the chain and a linear pass does not.
func TestSplitRedactionStaysLinearOnAChainOfSplitJWTs(t *testing.T) {
unit := splitThroughout(jwtToken, escSeparator) + nulSeparator
chain := strings.Repeat(unit, (64<<10)/len(unit))
signature := strings.Split(jwtToken, ".")[2]
if strings.Contains(rejoin(RedactString(chain, Options{})), signature) {
t.Fatal("SETUP INVALID: the chain is not redacted, so the run-on search never ran")
}
limit := int64(200 * len(chain))
if used := redactAllocBytes(chain); used > limit {
t.Errorf("redacting a %d byte chain of split JWTs allocated %d bytes, over the %d byte limit", len(chain), used, limit)
}
}
// A MATCH THE FILTER REJECTS MUST NOT HIDE THE KEY IT RAN INTO.
//
// The OpenAI shape is filtered, because kebab-case with no digit in it is prose
// and not a key. A rejected match still spans the text it matched, and its body
// runs on through a separator into whatever follows, so a split key after a
// kebab-case name was rejected along with the name and printed in full.
func TestSplitRedactionDoesNotLetARejectedNeighbourHideASplitKey(t *testing.T) {
const prose = "sk-some-kebab-case-name-here-ok"
const key = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh"
if !strings.Contains(RedactString(prose, Options{}), prose) {
t.Fatal("SETUP INVALID: the prose is redacted on its own, so it is not a rejected match")
}
if strings.Contains(RedactString(key, Options{}), key) {
t.Fatal("SETUP INVALID: the key is not redacted even whole, so it is not a credential")
}
value := prose + nulSeparator + splitInTheMiddle(key, escSeparator)
want := prose + nulSeparator + RedactedSecret
if got := RedactString(value, Options{}); got != want {
t.Errorf("RedactString(...) = %q, want %q", got, want)
}
}
// ORDINARY TEXT AFTER A KEY IS NOT PART OF THE KEY. Removing the separator
// between them joins the word behind it onto the end of the key, and an
// unbounded shape then carries the replacement over text that was never secret.
func TestSplitRedactionLeavesTextBehindAKeyAlone(t *testing.T) {
for _, testCase := range []struct {
name string
value string
want string
}{
{"word", openaiKey + nulSeparator + "ordinary prose here", RedactedSecret + nulSeparator + "ordinary prose here"},
{"escape then word", openaiKey + escSeparator + "ordinary", RedactedSecret + escSeparator + "ordinary"},
{"zero width then word", githubKey + zwspSeparator + "ordinary", RedactedSecret + zwspSeparator + "ordinary"},
{"digits", awsKey + nulSeparator + "0123456789", RedactedSecret + nulSeparator + "0123456789"},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, Options{}); got != testCase.want {
t.Errorf("RedactString(...) = %q, want %q", got, testCase.want)
}
})
}
}
// THE OFFSET TABLE IS SIZED BY THE MATCHES, NOT BY THE TEXT IT SEARCHED.
//
// RedactString is handed whole command output, verification stdout and
// notification bodies, none of which is bounded before it runs, and an index
// carrying a start and an end for every source byte costs sixteen bytes per byte
// of input on a 64-bit build. Measured against the same text with no separator
// in it, so the regex work and the output copy cancel out and what is left is
// what the split path added.
func TestSplitRedactionDoesNotIndexEverySourceByte(t *testing.T) {
const line = "plain log output line with no secret in it "
filler := strings.Repeat(line, (1<<20)/len(line))
split := filler + " " + splitInTheMiddle(awsKey, escSeparator)
plain := filler + " " + awsKey
if got := RedactString(split, Options{}); !strings.HasSuffix(got, RedactedSecret) {
t.Fatalf("SETUP INVALID: the split key in the large input is not redacted, so nothing indexes it: %q", got[len(got)-64:])
}
// Four times the input still fails an index of two ints per source byte,
// which needs sixteen, and clears the copy this path actually makes.
limit := int64(4 * len(split))
overhead := redactAllocBytes(split) - redactAllocBytes(plain)
if overhead > limit {
t.Errorf("redacting %d bytes with a split key allocated %d bytes more than the same text without one, over the %d byte limit",
len(split), overhead, limit)
}
}
// redactAllocBytes is the smallest number of bytes any of three RedactString
// calls allocated. Smallest rather than mean because a garbage collection or an
// unrelated goroutine can only ever add to the figure.
func redactAllocBytes(value string) int64 {
smallest := int64(-1)
for i := 0; i < 3; i++ {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
out := RedactString(value, Options{})
runtime.ReadMemStats(&after)
if len(out) == 0 {
panic("RedactString returned nothing")
}
used := int64(after.TotalAlloc - before.TotalAlloc)
if smallest < 0 || used < smallest {
smallest = used
}
}
return smallest
}
// A REPLACEMENT ALREADY ON THE PAGE IS A BARRIER. The default marker is spelled
// with brackets, which no credential body admits, so it stops a match by
// itself. A caller is free to supply one made of ordinary word characters, and
// then the text left by the contiguous pass would read as the middle of a key
// and carry the replacement out over both delimiters and the text behind them.
func TestSplitRedactionStopsAtACallerSuppliedReplacement(t *testing.T) {
options := Options{Replacement: "REDACTED"}
value := "sk-aaaaa" + nulSeparator + awsKey + nulSeparator + "bbbbbbbbbbbbbbb"
want := "sk-aaaaa" + nulSeparator + "REDACTED" + nulSeparator + "bbbbbbbbbbbbbbb"
if got := RedactString(value, options); got != want {
t.Errorf("RedactString(...) = %q, want %q", got, want)
}
}
// A REJECTED MATCH MUST NOT SHADOW THE REAL KEY BEHIND IT. Raised by CodeRabbit
// on #1067. With the separators removed before matching, the leftmost AWS match
// in "xAKIA<NUL>AKIAIOSF<SOH>ODNN7EXAMPLE" began at the decoy AKIA, was rejected
// for the "x" in front of it, and had already consumed the start of the real key,
// so the real key was never tried. "ask-" in front of a split OpenAI key does the
// same through "sk-", which is the ordinary-prose version of it. Each input is
// checked against its unsplit counterpart, which main already redacts.
func TestSplitRedactionDoesNotLetARejectedMatchShadowTheRealKey(t *testing.T) {
soh := string(rune(0x01))
for _, testCase := range []struct {
name string
split string
unsplit string
}{
{"decoy AKIA before a split key", "xAKIA" + nulSeparator + "AKIAIOSFODNN7E" + soh + "XAMPLE", "xAKIA" + nulSeparator + awsKey},
{"ask- before a split OpenAI key", "ask-" + nulSeparator + splitInTheMiddle(openaiKey, soh), "ask-" + nulSeparator + openaiKey},
{"task- before a split OpenAI key", "task-" + escSeparator + splitInTheMiddle(openaiKey, zwspSeparator), "task-" + escSeparator + openaiKey},
} {
t.Run(testCase.name, func(t *testing.T) {
want := RedactString(testCase.unsplit, Options{})
if strings.Contains(want, awsKey) || strings.Contains(want, openaiKey) {
t.Fatalf("SETUP INVALID: main does not redact the unsplit form either: %q", want)
}
if got := RedactString(testCase.split, Options{}); got != want {
t.Errorf("RedactString(split) = %q, want the unsplit form's %q", got, want)
}
})
}
}
// AN UNBOUNDED BODY MUST NOT TAKE A NEIGHBOUR'S PREFIX WITH IT. With gaps allowed
// between body characters, a split key's body runs on across a separator into
// whatever follows. When that is a JWT, it stops only at the JWT's first dot, and
// if the shapes were applied one after another the JWT shape would then find its
// header gone and leave the payload and signature in the clear. The shapes are
// matched against the same text and their union replaced, so it cannot.
func TestSplitRedactionKeepsANeighbourWhosePrefixASplitBodyWouldSwallow(t *testing.T) {
cut := len(jwtToken) / 2
splitJWT := jwtToken[:cut] + nulSeparator + jwtToken[cut:]
for _, testCase := range []struct {
name string
value string
}{
{"split Anthropic key, a word, then a split JWT", splitInTheMiddle(anthropicKey, zwspSeparator) + escSeparator + "build" + escSeparator + splitJWT},
{"split GitHub key, then a split JWT", splitInTheMiddle(githubKey, nulSeparator) + escSeparator + splitJWT},
} {
t.Run(testCase.name, func(t *testing.T) {
got := rejoin(RedactString(testCase.value, Options{}))
for _, piece := range strings.Split(jwtToken, ".") {
if strings.Contains(got, piece) {
t.Errorf("part of the JWT survives: %q in %q", piece, got)
}
}
})
}
}
// A CREDENTIAL GLUED ONTO A SPLIT ONE GETS ITS BOUNDARY FROM THE REPLACEMENT.
// Contiguous shapes cascade: once an AWS key is replaced, a JWT written straight
// after it has the "]" in front of it and matches. A split key is replaced by
// the second pass, so the JWT behind it needs a pass of its own after that one,
// and the JWT is often whole, which means its region may hold no separator at
// all by then. The second case adds a whole key elsewhere so that the strict
// pass has already cut the text into regions.
func TestSplitRedactionCascadesIntoACredentialGluedOntoASplitOne(t *testing.T) {
csi := string(rune(0x9b))
for _, testCase := range []struct {
name string
value string
}{
{"whole JWT glued onto a split AWS key", splitInTheMiddle(awsKey, escSeparator) + jwtToken},
{"same, with a whole key elsewhere in the text", "user?q=" + "ASIAIOSFODNN7EXA" + csi + "MPLE" + jwtToken + "'" + awsKey + string(rune(0x7f)) + "ok"},
} {
t.Run(testCase.name, func(t *testing.T) {
got := rejoin(RedactString(testCase.value, Options{}))
for _, piece := range strings.Split(jwtToken, ".") {
if strings.Contains(got, piece) {
t.Errorf("part of the JWT survives: %q in %q", piece, got)
}
}
})
}
}
// THE CLASS AND THE PREDICATE ARE ONE FACT. splitSeparatorClass is generated
// from the same tables as splitSecretSeparator; this walks every code point and
// fails on the first one they disagree about.
func TestSplitSeparatorClassAgreesWithThePredicate(t *testing.T) {
class := regexp.MustCompile("^" + splitSeparatorClass + "$")
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xd800 && r <= 0xdfff {
continue // surrogates are not valid in a Go string
}
if got, want := class.MatchString(string(r)), splitSecretSeparator(r); got != want {
t.Fatalf("U+%04X: class matches = %v, predicate says %v", r, got, want)
}
}
}
// ON TEXT WITH NO SEPARATOR, EVERY GAP-TOLERANT SHAPE IS ITS ORIGINAL. The
// rewrite only adds optional gaps, so a contiguous key has to produce exactly the
// same match; anything else means the rewrite changed what a shape is.
func TestGapTolerantShapesMatchContiguousKeysLikeTheOriginals(t *testing.T) {
inputs := []string{
"key=" + awsKey + " and " + githubKey,
"x" + awsKey,
anthropicKey + "." + openaiKey,
jwtToken + " then " + slackKey + ", " + googleKey,
"sk-this-is-kebab-case-prose-not-a-key",
"github_pat_11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyzABCDEFGH/glpat-abcdefghij0123456789",
}
originals := append([]*regexp.Regexp{openaiKeyPattern}, textSecretPatterns...)
tolerant := append([]*regexp.Regexp{splitOpenAIPattern}, splitTextPatterns...)
if len(originals) != len(tolerant) {
t.Fatalf("SETUP INVALID: %d original shapes and %d gap-tolerant ones", len(originals), len(tolerant))
}
for i := range originals {
for _, input := range inputs {
want := fmt.Sprint(originals[i].FindAllStringIndex(input, -1))
if got := fmt.Sprint(tolerant[i].FindAllStringIndex(input, -1)); got != want {
t.Errorf("shape %s on %q: gap-tolerant matched %s, original %s", originals[i], input, got, want)
}
}
}
}
+2 -6
View File
@@ -227,7 +227,8 @@ func RedactString(value string, options Options) string {
// openai keys first so the filter can drop kebab-case false positives
// before any other pattern rewrites nearby text.
redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string {
if !openAIShapeIsSecret(match) {
if !knownOpenAIKeyPrefix(match) && !secretMatchHasDigit(match) &&
strings.Contains(strings.TrimPrefix(match, "sk-"), "-") {
return match
}
return replacement
@@ -235,11 +236,6 @@ func RedactString(value string, options Options) string {
for _, pattern := range textSecretPatterns {
redacted = pattern.ReplaceAllString(redacted, replacement)
}
// Credentials broken up by invisible characters last, over what the
// contiguous matchers left: a whole credential is already claimed by its own
// strict match, so this pass cannot reach out of one and into the next. It is
// a no-op for text with no such character, which is nearly all text.
redacted = redactControlSplitSecrets(redacted, replacement)
return redacted
}