Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/redaction/betterleaks.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ var rulesConfig string
// betterleaks.toml, the betterleaks version, or the handling of findings in
// this file changes what gets redacted, so that policies can tell placeholders
// from different rulesets apart. Version 1 is the plain default ruleset.
const RulesetVersion = 3
const RulesetVersion = 4

var (
defaultScanner = sync.OnceValues(newBetterleaksScanner)
Expand Down
13 changes: 13 additions & 0 deletions internal/redaction/betterleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ regexes = [
'''^\([0-9]+(?:\.[0-9]+)?(?:ns|us|µs|ms|s|m|h)\)(?:\\+[nrt].*)?$''',
]

[[allowlists]]
description = "Exact password-field CSS selectors used by login helpers"
targetRules = ["generic-password"]
# Match only these complete selectors. Redactor JSON-encodes text before
# scanning, so plain quotes arrive with one backslash and JSON-encoded tool
# results with three. Keep those forms explicit and paired; do not accept
# arbitrary escaping or the whole line, which can carry other passwords.
regexes = [
'''^(?:\[id="1-password"\]|input\[name="password"\])$''',
'''^(?:\[id=\\"1-password\\"\]|input\[name=\\"password\\"\])$''',
'''^(?:\[id=\\{3}"1-password\\{3}"\]|input\[name=\\{3}"password\\{3}"\])$''',
]

[[allowlists]]
description = "Placeholders in documentation, such as Bearer YOUR_API_TOKEN"
targetRules = ["curl-auth-header"]
Expand Down
76 changes: 76 additions & 0 deletions internal/redaction/precision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package redaction

import (
"context"
"encoding/json"
"strings"
"testing"

Expand Down Expand Up @@ -164,6 +165,69 @@ func TestRulesConfigKeepsNonSecrets(t *testing.T) {
}
}

// Codex tool results carry JSON-encoded command output inside a text leaf.
// Check both that shape and plain output, with a real password beside the
// selector so the exemption cannot hide other findings in the same leaf.
func TestRedactPasswordFieldSelectors(t *testing.T) {
scanner, err := DefaultScanner()
require.NoError(t, err)
unfiltered := defaultRulesScanner(t)

for _, selector := range []string{`[id="1-password"]`, `input[name="password"]`} {
for _, encoded := range []bool{false, true} {
name := selector
if encoded {
name += "/json tool output"
}
t.Run(name, func(t *testing.T) {
text := "const AUTH0 = {\n email: 'input[name=\"login\"]',\n password: '" + selector + "',\n};\n"
wrap := func(text string) string {
if !encoded {
return text
}
b, err := json.Marshal(map[string]string{"output": text})
require.NoError(t, err)
return string(b)
}

_, report, err := New(unfiltered).RedactText(context.Background(), wrap(text))
require.NoError(t, err)
require.Contains(t, report.RuleIDs(), genericPasswordRuleID, "the original ruleset must detect the selector")

got, report, err := New(scanner).RedactText(context.Background(), wrap(text))
require.NoError(t, err)
assert.Equal(t, wrap(text), got)
assert.False(t, report.Changed())

text += "user: " + sampleEmail + "\npassword: " + samplePassword + ";\n"
got, report, err = New(scanner).RedactText(context.Background(), wrap(text))
require.NoError(t, err)
assert.Equal(t, wrap(strings.ReplaceAll(text, samplePassword, DefaultPlaceholder(genericPasswordRuleID))), got)
assert.Equal(t, map[string]int{genericPasswordRuleID: 1}, report.ByRule)
})
}
}
}

// Extra escaping must not turn a credential into an allowlisted selector.
func TestRedactOverescapedPasswordFieldSelectors(t *testing.T) {
scanner, err := DefaultScanner()
require.NoError(t, err)

for _, selector := range []string{`[id="1-password"]`, `input[name="password"]`} {
for _, backslashes := range []int{2, 3} {
secret := strings.ReplaceAll(selector, `"`, strings.Repeat(`\`, backslashes)+`"`)
t.Run(secret, func(t *testing.T) {
text := "user: " + sampleEmail + "\npassword: '" + secret + "'\n"
got, report, err := New(scanner).RedactText(context.Background(), text)
require.NoError(t, err)
assert.Equal(t, strings.ReplaceAll(text, secret, DefaultPlaceholder(genericPasswordRuleID)), got)
assert.Equal(t, map[string]int{genericPasswordRuleID: 1}, report.ByRule)
})
}
}
}

// TestRulesConfigStillRedactsSecrets is the counterpart of
// TestRulesConfigKeepsNonSecrets: a real secret of each shape is still
// replaced.
Expand Down Expand Up @@ -204,6 +268,18 @@ func TestRulesConfigStillRedactsSecrets(t *testing.T) {
secret: sampleAPIKey,
wantRule: "curl-auth-header",
},
{
name: "password with a selector suffix",
text: "user: " + sampleEmail + "\npassword: '" + samplePassword + `[id="1-password"]'` + "\n",
secret: samplePassword,
wantRule: genericPasswordRuleID,
},
{
name: "password with a selector prefix",
text: "user: " + sampleEmail + "\npassword: '" + `input[name="password"]` + samplePassword + "'\n",
secret: samplePassword,
wantRule: genericPasswordRuleID,
},
{
name: "password with a logical operator in it",
text: "host: db.internal\nuser: " + sampleEmail + "\npassword: Xk9m||Q2vL" + "p7wRt4z\n",
Expand Down
Loading