Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create global log redaction capability #3522

Merged
merged 17 commits into from
Oct 29, 2024
14 changes: 7 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine"
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
"github.com/trufflesecurity/trufflehog/v3/pkg/handlers"
"github.com/trufflesecurity/trufflehog/v3/pkg/log"
"github.com/trufflesecurity/trufflehog/v3/pkg/output"
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
"github.com/trufflesecurity/trufflehog/v3/pkg/tui"
"github.com/trufflesecurity/trufflehog/v3/pkg/updater"
Expand Down Expand Up @@ -72,10 +72,10 @@ var (
jobReportFile = cli.Flag("output-report", "Write a scan report to the provided path.").Hidden().OpenFile(os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)

// Add feature flags
forceSkipBinaries = cli.Flag("force-skip-binaries", "Force skipping binaries.").Bool()
forceSkipArchives = cli.Flag("force-skip-archives", "Force skipping archives.").Bool()
forceSkipBinaries = cli.Flag("force-skip-binaries", "Force skipping binaries.").Bool()
forceSkipArchives = cli.Flag("force-skip-archives", "Force skipping archives.").Bool()
skipAdditionalRefs = cli.Flag("skip-additional-refs", "Skip additional references.").Bool()
userAgentSuffix = cli.Flag("user-agent-suffix", "Suffix to add to User-Agent.").String()
userAgentSuffix = cli.Flag("user-agent-suffix", "Suffix to add to User-Agent.").String()

gitScan = cli.Command("git", "Find credentials in git repositories.")
gitScanURI = gitScan.Arg("uri", "Git repository URL. https://, file://, or ssh:// schema expected.").Required().String()
Expand Down Expand Up @@ -285,7 +285,7 @@ func main() {
if *jsonOut {
logFormat = log.WithJSONSink
}
logger, sync := log.New("trufflehog", logFormat(os.Stderr))
logger, sync := log.New("trufflehog", logFormat(os.Stderr, log.WithGlobalRedaction()))
// make it the default logger for contexts
context.SetDefaultLogger(logger)

Expand Down Expand Up @@ -375,15 +375,15 @@ func run(state overseer.State) {
}()
}

// Set feature configurations from CLI flags
// Set feature configurations from CLI flags
if *forceSkipBinaries {
feature.ForceSkipBinaries.Store(true)
}

if *forceSkipArchives {
feature.ForceSkipArchives.Store(true)
}

if *skipAdditionalRefs {
feature.SkipAdditionalRefs.Store(true)
}
Expand Down
41 changes: 0 additions & 41 deletions pkg/log/core.go

This file was deleted.

50 changes: 50 additions & 0 deletions pkg/log/dynamic_redactor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package log

import (
"strings"
"sync"
"sync/atomic"
)

type dynamicRedactor struct {
denySet map[string]struct{}
denySlice []string
denyMu sync.Mutex

replacer atomic.Pointer[strings.Replacer]
}

var globalRedactor *dynamicRedactor

func init() {
globalRedactor = &dynamicRedactor{denySet: make(map[string]struct{})}
globalRedactor.replacer.CompareAndSwap(nil, strings.NewReplacer())
}

// RedactGlobally configures the global log redactor to redact the provided value during log emission. The value will be
// redacted in log messages and values that are strings, but not in log keys or values of other types.
func RedactGlobally(sensitiveValue string) {
globalRedactor.configureForRedaction(sensitiveValue)
}

func (r *dynamicRedactor) configureForRedaction(sensitiveValue string) {
if sensitiveValue == "" {
return
}

r.denyMu.Lock()
defer r.denyMu.Unlock()

if _, ok := r.denySet[sensitiveValue]; ok {
return
}

r.denySet[sensitiveValue] = struct{}{}
r.denySlice = append(r.denySlice, sensitiveValue, "*****")

r.replacer.Store(strings.NewReplacer(r.denySlice...))
}

func (r *dynamicRedactor) redact(s string) string {
return r.replacer.Load().Replace(s)
}
1 change: 0 additions & 1 deletion pkg/log/level.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"go.uber.org/zap/zapcore"
)

// TODO: Use a struct to make testing easier.
var (
// Global, default log level control.
globalLogLevel levelSetter = zap.NewAtomicLevel()
Expand Down
30 changes: 21 additions & 9 deletions pkg/log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ func WithSentry(opts sentry.ClientOptions, tags map[string]string) logConfig {
}

type sinkConfig struct {
encoder zapcore.Encoder
sink zapcore.WriteSyncer
level levelSetter
encoder zapcore.Encoder
sink zapcore.WriteSyncer
level levelSetter
redactor *dynamicRedactor
}

// WithJSONSink adds a JSON encoded output to the logger.
Expand Down Expand Up @@ -176,6 +177,13 @@ func WithLeveler(leveler levelSetter) func(*sinkConfig) {
}
}

// WithGlobalRedaction adds values to be redacted from logs.
func WithGlobalRedaction() func(*sinkConfig) {
return func(conf *sinkConfig) {
conf.redactor = globalRedactor
}
}

// firstErrorFunc is a helper function that returns a function that executes
// all provided args and returns the first error, if any.
func firstErrorFunc(fs ...func() error) func() error {
Expand Down Expand Up @@ -209,11 +217,15 @@ func newCoreConfig(
for _, f := range opts {
f(&conf)
}
return logConfig{
core: zapcore.NewCore(
conf.encoder,
conf.sink,
conf.level,
),
core := zapcore.NewCore(
conf.encoder,
conf.sink,
conf.level,
)

if conf.redactor == nil {
return logConfig{core: core}
}

return logConfig{core: NewRedactionCore(core, conf.redactor)}
}
125 changes: 123 additions & 2 deletions pkg/log/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import (

"github.com/getsentry/sentry-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)

func TestNew(t *testing.T) {
var jsonBuffer, consoleBuffer bytes.Buffer
logger, flush := New("service-name",
WithJSONSink(&jsonBuffer),
WithConsoleSink(&consoleBuffer),
WithJSONSink(&jsonBuffer, WithGlobalRedaction()),
WithConsoleSink(&consoleBuffer, WithGlobalRedaction()),
)
logger.Info("yay")
assert.Nil(t, flush())
Expand Down Expand Up @@ -233,3 +234,123 @@ func TestFindLevel(t *testing.T) {
assert.Equal(t, i8, findLevel(logger))
}
}

func TestGlobalRedaction_Console(t *testing.T) {
oldState := globalRedactor
globalRedactor = &dynamicRedactor{
denySet: make(map[string]struct{}),
}
defer func() { globalRedactor = oldState }()

var buf bytes.Buffer
logger, flush := New("console-redaction-test",
WithConsoleSink(&buf, WithGlobalRedaction()),
)
RedactGlobally("foo")
RedactGlobally("bar")

logger.Info("this foo is :bar",
"foo", "bar",
"array", []string{"foo", "bar", "baz"},
"object", map[string]string{"foo": "bar"})
require.NoError(t, flush())

gotParts := strings.Split(buf.String(), "\t")[1:] // The first item is the timestamp
wantParts := []string{
"info-0",
"console-redaction-test",
"this ***** is :*****",
"{\"foo\": \"*****\", \"array\": [\"foo\", \"bar\", \"baz\"], \"object\": {\"foo\":\"bar\"}}\n",
}
assert.Equal(t, wantParts, gotParts)
}

func TestGlobalRedaction_JSON(t *testing.T) {
oldState := globalRedactor
globalRedactor = &dynamicRedactor{
denySet: make(map[string]struct{}),
}
defer func() { globalRedactor = oldState }()

var jsonBuffer bytes.Buffer
logger, flush := New("json-redaction-test",
WithJSONSink(&jsonBuffer, WithGlobalRedaction()),
)
RedactGlobally("foo")
RedactGlobally("bar")
logger.Info("this foo is :bar",
"foo", "bar",
"array", []string{"foo", "bar", "baz"},
"object", map[string]string{"foo": "bar"})
require.NoError(t, flush())

var parsedJSON map[string]any
require.NoError(t, json.Unmarshal(jsonBuffer.Bytes(), &parsedJSON))
assert.NotEmpty(t, parsedJSON["ts"])
delete(parsedJSON, "ts")
assert.Equal(t,
map[string]any{
"level": "info-0",
"logger": "json-redaction-test",
"msg": "this ***** is :*****",
"foo": "*****",
"array": []any{"foo", "bar", "baz"},
"object": map[string]interface{}{"foo": "bar"},
},
parsedJSON,
)
}

func BenchmarkLoggerRedact(b *testing.B) {
msg := "this is a message with 'foo' in it"
logKvps := []any{"key", "value", "foo", "bar", "bar", "baz", "longval", "84hblnqwp97ewilbgoab8fhqlngahs6dl3i269haa"}
redactor := &dynamicRedactor{denySet: make(map[string]struct{})}
redactor.replacer.CompareAndSwap(nil, strings.NewReplacer())

b.Run("no redaction", func(b *testing.B) {
logger, flush := New("redaction-benchmark", WithJSONSink(
io.Discard,
func(conf *sinkConfig) { conf.redactor = redactor },
))
for i := 0; i < b.N; i++ {
logger.Info(msg, logKvps...)
}
require.NoError(b, flush())
})
b.Run("1 redaction", func(b *testing.B) {
logger, flush := New("redaction-benchmark", WithJSONSink(
io.Discard,
func(conf *sinkConfig) { conf.redactor = redactor },
))
redactor.configureForRedaction("84hblnqwp97ewilbgoab8fhqlngahs6dl3i269haa")
for i := 0; i < b.N; i++ {
logger.Info(msg, logKvps...)
}
require.NoError(b, flush())
})
b.Run("2 redactions", func(b *testing.B) {
logger, flush := New("redaction-benchmark", WithJSONSink(
io.Discard,
func(conf *sinkConfig) { conf.redactor = redactor },
))
redactor.configureForRedaction("84hblnqwp97ewilbgoab8fhqlngahs6dl3i269haa")
redactor.configureForRedaction("foo")
for i := 0; i < b.N; i++ {
logger.Info(msg, logKvps...)
}
require.NoError(b, flush())
})
b.Run("3 redactions", func(b *testing.B) {
logger, flush := New("redaction-benchmark", WithJSONSink(
io.Discard,
func(conf *sinkConfig) { conf.redactor = redactor },
))
redactor.configureForRedaction("84hblnqwp97ewilbgoab8fhqlngahs6dl3i269haa")
redactor.configureForRedaction("foo")
redactor.configureForRedaction("bar")
for i := 0; i < b.N; i++ {
logger.Info(msg, logKvps...)
}
require.NoError(b, flush())
})
}
42 changes: 42 additions & 0 deletions pkg/log/redaction_core.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package log

import (
"go.uber.org/zap/zapcore"
)

// redactionCore wraps a zapcore.Core to perform redaction of log messages in
// the message and field values.
type redactionCore struct {
zapcore.Core
redactor *dynamicRedactor
}

// NewRedactionCore creates a zapcore.Core that performs redaction of logs in
// the message and field values.
func NewRedactionCore(core zapcore.Core, redactor *dynamicRedactor) zapcore.Core {
return &redactionCore{core, redactor}
}

// Check overrides the embedded zapcore.Core Check() method to add the
// redactionCore to the zapcore.CheckedEntry.
func (c *redactionCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(ent.Level) {
return ce.AddCore(ent, c)
}
return ce
}

func (c *redactionCore) With(fields []zapcore.Field) zapcore.Core {
return NewRedactionCore(c.Core.With(fields), c.redactor)
}

// Write overrides the embedded zapcore.Core Write() method to redact the message and fields before passing them to be
// written. Only message and string values are redacted; keys and non-string values (e.g. those inside of arrays and
// structured objects) are not redacted.
func (c *redactionCore) Write(ent zapcore.Entry, fields []zapcore.Field) error {
ent.Message = c.redactor.redact(ent.Message)
for i := range fields {
fields[i].String = c.redactor.redact(fields[i].String)
}
return c.Core.Write(ent, fields)
}
Loading
Loading